1use std::collections::{BTreeMap, HashSet};
21use std::path::{Path, PathBuf};
22
23#[cfg(unix)]
24use std::os::unix::fs::{MetadataExt, PermissionsExt};
25
26use serde::{Deserialize, Serialize};
27
28use crate::anthropic::creds::CredsTarget;
29use crate::cache::Cache;
30use crate::error::{AppError, Result};
31use crate::vendor::VendorId;
32
33#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct Config {
42 pub ui: UiConfig,
43 pub tray: TrayConfig,
44 pub context: ContextConfig,
45 pub anthropic: AnthropicConfig,
46 pub anthropic_api: AnthropicApiConfig,
47 pub openai: OpenAiConfig,
48 pub copilot: CopilotConfig,
49 pub zai: ZaiConfig,
50 pub openrouter: OpenRouterConfig,
51 pub deepseek: DeepseekConfig,
52 pub kimi: KimiConfig,
53 pub kilo: KiloConfig,
54 pub novita: NovitaConfig,
55 pub moonshot: MoonshotConfig,
56 pub grok: GrokConfig,
57 pub supergrok: SuperGrokConfig,
58 pub grokbot: GrokbotConfig,
59 pub antigravity: AntigravityConfig,
60 pub cursor: CursorConfig,
61 pub minimax: MinimaxConfig,
62 pub kiro: KiroConfig,
63 pub nous: NousConfig,
64 #[serde(rename = "opencode-go")]
65 pub opencode_go: OpenCodeGoConfig,
66 pub commandcode: CommandCodeConfig,
67 pub ollama: OllamaConfig,
68 pub custom: Vec<CustomProviderConfig>,
70}
71
72#[derive(Debug, Clone, Default, Deserialize, Serialize)]
76#[serde(default)]
77pub struct UiConfig {
78 pub primary: Option<VendorId>,
80 pub overview_vendors: Option<Vec<VendorId>>,
84 pub vendor_box: Option<VendorBoxStyle>,
86}
87
88impl UiConfig {
89 pub fn vendor_box(&self) -> VendorBoxStyle {
90 self.vendor_box.unwrap_or_default()
91 }
92}
93
94#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
99#[serde(default)]
100pub struct TrayConfig {
101 pub shortcut: Option<String>,
104 pub refresh_minutes: Option<u64>,
107 pub updates: Option<UpdateMode>,
109}
110
111pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
114const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
115
116impl TrayConfig {
117 pub fn refresh_minutes(&self) -> u64 {
118 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
119 }
120
121 pub fn updates(&self) -> UpdateMode {
122 self.updates.unwrap_or_default()
123 }
124}
125
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
129#[serde(rename_all = "lowercase")]
130pub enum UpdateMode {
131 Auto,
132 #[default]
133 Notify,
134 Off,
135}
136
137impl UpdateMode {
138 pub fn as_str(self) -> &'static str {
139 match self {
140 Self::Auto => "auto",
141 Self::Notify => "notify",
142 Self::Off => "off",
143 }
144 }
145
146 pub fn parse(text: &str) -> Option<Self> {
147 match text.trim().to_ascii_lowercase().as_str() {
148 "auto" => Some(Self::Auto),
149 "notify" => Some(Self::Notify),
150 "off" => Some(Self::Off),
151 _ => None,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
158#[serde(rename_all = "lowercase")]
159pub enum VendorBoxStyle {
160 #[default]
162 Sidebar,
163 Navbar,
165 None,
167}
168
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
172#[serde(rename_all = "lowercase")]
173pub enum ContextLayout {
174 #[default]
176 Full,
177 Split,
179 Bottom,
181}
182
183impl ContextLayout {
184 pub fn next(self) -> Self {
185 match self {
186 ContextLayout::Full => ContextLayout::Split,
187 ContextLayout::Split => ContextLayout::Bottom,
188 ContextLayout::Bottom => ContextLayout::Full,
189 }
190 }
191
192 pub fn label(self) -> &'static str {
193 match self {
194 ContextLayout::Full => "full",
195 ContextLayout::Split => "split",
196 ContextLayout::Bottom => "bottom",
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Deserialize, Serialize)]
206#[serde(default)]
207pub struct ContextConfig {
208 pub enabled: bool,
211 pub projects_path: Option<PathBuf>,
213 pub context_window_tokens: Option<u64>,
216 pub model_context_window_tokens: BTreeMap<String, u64>,
219 pub layout: ContextLayout,
221}
222
223impl ContextConfig {
224 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
225 model
226 .and_then(|model| self.model_context_window_tokens.get(model).copied())
227 .filter(|tokens| *tokens > 0)
228 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
229 }
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize)]
233#[serde(default)]
234pub struct AnthropicConfig {
235 pub enabled: bool,
236 pub credentials_path: Option<PathBuf>,
239 pub accounts: Vec<AnthropicAccount>,
243 pub accounts_dir: Option<PathBuf>,
251 pub show_default_account: bool,
257 pub desktop_profiles_dir: Option<PathBuf>,
263}
264
265impl Default for AnthropicConfig {
266 fn default() -> Self {
267 Self {
268 enabled: true,
269 credentials_path: None,
270 accounts: Vec::new(),
271 accounts_dir: None,
272 show_default_account: true,
273 desktop_profiles_dir: None,
274 }
275 }
276}
277
278#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
288pub struct AnthropicAccount {
289 pub label: String,
292 pub credentials_path: PathBuf,
296}
297
298impl AnthropicAccount {
299 pub fn config_dir(&self) -> PathBuf {
304 self.credentials_path
305 .parent()
306 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
307 }
308}
309
310impl AnthropicConfig {
311 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
317 let mut out = self.accounts.clone();
318 if let Some(dir) = &self.accounts_dir {
319 for acct in discover_accounts(dir) {
320 if !out.iter().any(|a| a.label == acct.label) {
321 out.push(acct);
322 }
323 }
324 }
325 out
326 }
327
328 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
333 validate_account_label(label)?;
334 let all = self.all_accounts();
335 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
336 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
337 AppError::Credentials(format!(
338 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
339 known labels: {known:?}"
340 ))
341 })
342 }
343
344 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
355 let active = crate::anthropic::cli_account::home_claude_json()
356 .ok()
357 .and_then(|path| {
358 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
359 });
360 self.account_target_with(label, active.as_deref())
361 }
362
363 pub fn account_target_with(
374 &self,
375 label: &str,
376 cli_active: Option<&str>,
377 ) -> Result<(CredsTarget, Cache)> {
378 let account = self.account(label)?;
379 let cache = Cache::for_vendor_account("anthropic", label)?;
380 if cli_active == Some(label) {
381 return Ok((
382 CredsTarget::Default(crate::anthropic::creds::default_path()?),
383 cache,
384 ));
385 }
386 Ok((
387 CredsTarget::Named {
388 config_dir: account.config_dir(),
389 path: account.credentials_path,
390 },
391 cache,
392 ))
393 }
394}
395
396pub fn validate_account_label(label: &str) -> Result<()> {
402 validate_account_label_for("anthropic", label)
403}
404
405fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
406 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
407 let bad = label.is_empty()
408 || label == "."
409 || label == ".."
410 || label.contains(['/', '\\'])
411 || label.contains(':')
412 || label.chars().any(char::is_control)
413 || RESERVED.contains(&label);
414 if bad {
415 return Err(AppError::Credentials(format!(
416 "invalid {vendor} account label {label:?}: must be a non-empty name \
417 without path separators, drive prefixes, control characters, or reserved cache names"
418 )));
419 }
420 Ok(())
421}
422
423fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
431 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
432 return Vec::new();
433 };
434 let mut found: Vec<AnthropicAccount> = entries
435 .flatten()
436 .filter_map(|entry| {
437 let path = entry.path();
438 if !path.is_dir() {
439 return None;
440 }
441 let label = path.file_name()?.to_str()?.to_string();
442 validate_account_label(&label).ok()?;
443 Some(AnthropicAccount {
444 label,
445 credentials_path: path.join(".credentials.json"),
446 })
447 })
448 .collect();
449 found.sort_by(|a, b| a.label.cmp(&b.label));
450 found
451}
452
453pub fn tildify(path: &Path, home: &Path) -> String {
457 path.strip_prefix(home)
458 .map(|rest| {
459 let rendered = rest.display().to_string();
460 #[cfg(windows)]
463 let rendered = rendered.replace('\\', "/");
464 format!("~/{rendered}")
465 })
466 .unwrap_or_else(|_| path.display().to_string())
467}
468
469pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
474 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
475 base.join("accounts").join(label).join(".credentials.json")
476}
477
478pub fn add_anthropic_account_to_doc(
484 doc: &mut toml_edit::DocumentMut,
485 label: &str,
486 credentials_path: &str,
487) -> Result<()> {
488 use toml_edit::{Item, Table, value};
489
490 validate_account_label(label)?;
491
492 let anthropic = doc
493 .entry("anthropic")
494 .or_insert_with(|| Item::Table(Table::new()));
495 let anthropic = anthropic
496 .as_table_mut()
497 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
498
499 let accounts = anthropic
500 .entry("accounts")
501 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
502 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
503 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
504 })?;
505
506 let exists = accounts
507 .iter()
508 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
509 if exists {
510 return Err(AppError::Credentials(format!(
511 "anthropic account {label:?} already exists in config.toml"
512 )));
513 }
514
515 let mut table = Table::new();
516 table["label"] = value(label);
517 table["credentials_path"] = value(credentials_path);
518 accounts.push(table);
519 Ok(())
520}
521
522pub(crate) fn set_bool(
526 doc: &mut toml_edit::DocumentMut,
527 section: &str,
528 key: &str,
529 new_value: bool,
530) -> Result<()> {
531 let table = doc
532 .entry(section)
533 .or_insert_with(toml_edit::table)
534 .as_table_mut()
535 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
536
537 if let Some(item) = table.get_mut(key)
538 && let Some(v) = item.as_value_mut()
539 {
540 let suffix = v.decor().suffix().cloned();
544 *v = toml_edit::Value::from(new_value);
545 v.decor_mut().set_prefix(" ");
546 if let Some(suffix) = suffix {
547 v.decor_mut().set_suffix(suffix);
548 }
549 return Ok(());
550 }
551 table.insert(key, toml_edit::value(new_value));
552 Ok(())
553}
554
555pub(crate) fn set_value(
561 doc: &mut toml_edit::DocumentMut,
562 section: &str,
563 key: &str,
564 new_value: Option<toml_edit::Value>,
565) -> Result<()> {
566 let table = doc
567 .entry(section)
568 .or_insert_with(toml_edit::table)
569 .as_table_mut()
570 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
571
572 let Some(mut new_value) = new_value else {
573 table.remove(key);
574 return Ok(());
575 };
576 if let Some(item) = table.get_mut(key)
577 && let Some(v) = item.as_value_mut()
578 {
579 let suffix = v.decor().suffix().cloned();
580 new_value.decor_mut().set_prefix(" ");
581 if let Some(suffix) = suffix {
582 new_value.decor_mut().set_suffix(suffix);
583 }
584 *v = new_value;
585 return Ok(());
586 }
587 table.insert(key, toml_edit::Item::Value(new_value));
588 Ok(())
589}
590
591pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
597 let mut doc = read_config_document(path)?;
598 let before = doc.to_string();
599 set_value(&mut doc, "tray", key, value)?;
600 if doc.to_string() == before {
601 return Ok(());
602 }
603 write_config_document(path, &doc)
604}
605
606pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
610 let original = match std::fs::read_to_string(path) {
611 Ok(contents) => contents,
612 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
613 Err(error) => return Err(AppError::io_at(path, error)),
614 };
615 if original.trim().is_empty() {
616 return Ok(toml_edit::DocumentMut::new());
617 }
618 original.parse().map_err(|e: toml_edit::TomlError| {
619 AppError::Other(format!("config.toml not parseable: {e}"))
620 })
621}
622
623pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
627 let bytes = doc.to_string();
628 crate::cache::atomic_write(path, bytes.as_bytes())?;
629
630 #[cfg(unix)]
631 {
632 if let Ok(meta) = std::fs::metadata(path) {
633 let mut perms = meta.permissions();
634 perms.set_mode(0o600);
635 let _ = std::fs::set_permissions(path, perms);
636 }
637 }
638 Ok(())
639}
640
641pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
649 let mut doc = read_config_document(path)?;
650 let before = doc.to_string();
651 let written: Vec<VendorId> = vendors
652 .iter()
653 .copied()
654 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
655 .collect();
656 for vendor in &written {
657 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
658 }
659 if doc.to_string() == before {
660 return Ok(written);
661 }
662 write_config_document(path, &doc)?;
663 Ok(written)
664}
665
666fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
677 doc.get(vendor.config_section())
678 .and_then(|section| section.get("enabled"))
679 .and_then(|enabled| enabled.as_bool())
680 == Some(false)
681}
682
683#[derive(Debug, Clone, Deserialize, Serialize)]
684#[serde(default)]
685pub struct OpenAiConfig {
686 pub enabled: bool,
687 pub codex_auth_path: Option<PathBuf>,
689 #[serde(default)]
694 pub accounts: Vec<OpenAiAccount>,
695 pub admin_key_env: String,
703}
704
705#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
716pub struct OpenAiAccount {
717 pub label: String,
720 pub codex_auth_path: PathBuf,
723}
724
725impl OpenAiConfig {
726 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
730 let Some(label) = label else {
731 return match &self.codex_auth_path {
732 Some(path) => Ok(path.clone()),
733 None => crate::openai::creds::default_path(),
734 };
735 };
736 self.accounts
737 .iter()
738 .find(|account| account.label == label)
739 .map(|account| account.codex_auth_path.clone())
740 .ok_or_else(|| {
741 AppError::Credentials(format!(
742 "no OpenAI account named {label:?}. Add it under \
743 [[openai.accounts]], or drop --account to use the default login."
744 ))
745 })
746 }
747}
748
749impl Default for OpenAiConfig {
750 fn default() -> Self {
751 Self {
752 enabled: true,
753 codex_auth_path: None,
754 accounts: Vec::new(),
755 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
756 }
757 }
758}
759
760#[derive(Debug, Clone, Default, Deserialize, Serialize)]
764#[serde(default)]
765pub struct CopilotConfig {
766 pub enabled: bool,
767 pub gh_binary: Option<PathBuf>,
770}
771
772impl CopilotConfig {
773 pub fn resolve_token(&self) -> Result<String> {
774 self.resolve_token_with(
775 |name| std::env::var_os(name),
776 &crate::copilot::credentials::SystemGhAuthTokenRunner,
777 )
778 }
779
780 fn resolve_token_with(
781 &self,
782 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
783 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
784 ) -> Result<String> {
785 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
786 let token = value.into_string().map_err(|_| {
787 AppError::Credentials(
788 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
789 )
790 })?;
791 if !token.is_empty() {
792 return Ok(token);
793 }
794 }
795 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
796 }
797}
798
799#[derive(Debug, Clone, Default, Deserialize, Serialize)]
800#[serde(default)]
801pub struct NousConfig {
802 pub enabled: bool,
803}
804
805#[derive(Debug, Clone, Deserialize, Serialize)]
806#[serde(default)]
807pub struct OpenCodeGoConfig {
808 pub enabled: bool,
809 pub api_key_env: String,
810 pub api_key: Option<String>,
811}
812
813#[derive(Debug, Clone, Default, Deserialize, Serialize)]
817#[serde(default)]
818pub struct CommandCodeConfig {
819 pub enabled: bool,
820 pub auth_paths: Option<Vec<PathBuf>>,
821}
822
823#[derive(Debug, Clone, Deserialize, Serialize)]
828#[serde(default)]
829pub struct OllamaConfig {
830 pub enabled: bool,
831 pub api_key_env: String,
832 pub api_key: Option<String>,
833 pub plan: String,
836}
837
838impl Default for OllamaConfig {
839 fn default() -> Self {
840 Self {
841 enabled: false,
842 api_key_env: "OLLAMA_API_KEY".to_string(),
843 api_key: None,
844 plan: "pro".to_string(),
845 }
846 }
847}
848
849impl Default for OpenCodeGoConfig {
850 fn default() -> Self {
851 Self {
852 enabled: false,
853 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
854 api_key: None,
855 }
856 }
857}
858
859#[derive(Debug, Clone, Deserialize, Serialize)]
860#[serde(default)]
861pub struct ZaiConfig {
862 pub enabled: bool,
863 pub api_key_env: String,
865 pub api_key: Option<String>,
868 pub plan_tier: Option<String>,
870}
871
872impl Default for ZaiConfig {
873 fn default() -> Self {
874 Self {
875 enabled: true,
876 api_key_env: "ZAI_API_KEY".to_string(),
877 api_key: None,
878 plan_tier: None,
879 }
880 }
881}
882
883#[derive(Debug, Clone, Deserialize, Serialize)]
884#[serde(default)]
885pub struct OpenRouterConfig {
886 pub enabled: bool,
887 pub accounts: Vec<OpenRouterAccount>,
890 pub show_default_account: bool,
894 pub api_key_env: String,
895 pub api_key: Option<String>,
896}
897
898impl Default for OpenRouterConfig {
899 fn default() -> Self {
900 Self {
901 enabled: true,
902 accounts: Vec::new(),
903 show_default_account: true,
904 api_key_env: "OPENROUTER_API_KEY".to_string(),
905 api_key: None,
906 }
907 }
908}
909
910#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
913pub struct OpenRouterAccount {
914 pub label: String,
916 #[serde(default)]
918 pub api_key_env: Option<String>,
919 #[serde(default)]
921 pub api_key: Option<String>,
922}
923
924impl OpenRouterConfig {
925 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
928 validate_account_label_for("openrouter", label)?;
929 self.accounts
930 .iter()
931 .find(|account| account.label == label)
932 .ok_or_else(|| {
933 let known: Vec<&str> = self
934 .accounts
935 .iter()
936 .map(|account| account.label.as_str())
937 .collect();
938 AppError::Credentials(format!(
939 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
940 known labels: {known:?}"
941 ))
942 })
943 }
944
945 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
948 match label {
949 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
950 Some(label) => {
951 let account = self.account(label)?;
952 resolve_api_key_in_section(
953 &format!("OpenRouter account {label:?}"),
954 "[[openrouter.accounts]]",
955 account.api_key_env.as_deref().unwrap_or(""),
956 account.api_key.as_deref(),
957 )
958 }
959 }
960 }
961}
962
963#[derive(Debug, Clone, Deserialize, Serialize)]
964#[serde(default)]
965pub struct DeepseekConfig {
966 pub enabled: bool,
967 pub api_key_env: String,
968 pub api_key: Option<String>,
969}
970
971impl Default for DeepseekConfig {
972 fn default() -> Self {
973 Self {
974 enabled: false,
975 api_key_env: "DEEPSEEK_API_KEY".to_string(),
976 api_key: None,
977 }
978 }
979}
980
981#[derive(Debug, Clone, Deserialize, Serialize)]
982#[serde(default)]
983pub struct KimiConfig {
984 pub enabled: bool,
985 pub api_key_env: String,
986 pub api_key: Option<String>,
989 pub credentials_path: Option<PathBuf>,
993 pub region: String,
998}
999
1000impl Default for KimiConfig {
1001 fn default() -> Self {
1002 Self {
1003 enabled: false,
1004 api_key_env: "KIMI_API_KEY".to_string(),
1005 api_key: None,
1006 credentials_path: None,
1007 region: "auto".to_string(),
1008 }
1009 }
1010}
1011
1012#[derive(Debug, Clone, Deserialize, Serialize)]
1013#[serde(default)]
1014pub struct KiloConfig {
1015 pub enabled: bool,
1016 pub api_key_env: String,
1017 pub api_key: Option<String>,
1018 pub organization_id: Option<String>,
1021}
1022
1023impl Default for KiloConfig {
1024 fn default() -> Self {
1025 Self {
1028 enabled: false,
1029 api_key_env: "KILO_API_KEY".to_string(),
1030 api_key: None,
1031 organization_id: None,
1032 }
1033 }
1034}
1035
1036#[derive(Debug, Clone, Deserialize, Serialize)]
1037#[serde(default)]
1038pub struct NovitaConfig {
1039 pub enabled: bool,
1040 pub api_key_env: String,
1041 pub api_key: Option<String>,
1042}
1043
1044impl Default for NovitaConfig {
1045 fn default() -> Self {
1046 Self {
1048 enabled: false,
1049 api_key_env: "NOVITA_API_KEY".to_string(),
1050 api_key: None,
1051 }
1052 }
1053}
1054
1055#[derive(Debug, Clone, Deserialize, Serialize)]
1056#[serde(default)]
1057pub struct MinimaxConfig {
1058 pub enabled: bool,
1059 pub api_key_env: String,
1060 pub api_key: Option<String>,
1061 pub region: String,
1067}
1068
1069impl Default for MinimaxConfig {
1070 fn default() -> Self {
1071 Self {
1073 enabled: false,
1074 api_key_env: "MINIMAX_API_KEY".to_string(),
1075 api_key: None,
1076 region: "global".to_string(),
1077 }
1078 }
1079}
1080
1081#[derive(Debug, Clone, Deserialize, Serialize)]
1082#[serde(default)]
1083pub struct MoonshotConfig {
1084 pub enabled: bool,
1085 pub api_key_env: String,
1086 pub api_key: Option<String>,
1087 pub region: String,
1089}
1090
1091impl Default for MoonshotConfig {
1092 fn default() -> Self {
1093 Self {
1095 enabled: false,
1096 api_key_env: "MOONSHOT_API_KEY".to_string(),
1097 api_key: None,
1098 region: "global".to_string(),
1099 }
1100 }
1101}
1102
1103#[derive(Debug, Clone, Deserialize, Serialize)]
1104#[serde(default)]
1105pub struct GrokConfig {
1106 pub enabled: bool,
1107 pub api_key_env: String,
1109 pub api_key: Option<String>,
1110 pub team_id: Option<String>,
1113}
1114
1115impl Default for GrokConfig {
1116 fn default() -> Self {
1117 Self {
1119 enabled: false,
1120 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1121 api_key: None,
1122 team_id: None,
1123 }
1124 }
1125}
1126
1127#[derive(Debug, Clone, Deserialize, Serialize)]
1135#[serde(default)]
1136pub struct SuperGrokConfig {
1137 pub enabled: bool,
1138 pub grok_binary: PathBuf,
1142 pub auth_path: Option<PathBuf>,
1145 pub config_path: Option<PathBuf>,
1146}
1147
1148impl Default for SuperGrokConfig {
1149 fn default() -> Self {
1150 Self {
1151 enabled: false,
1152 grok_binary: default_grok_binary(),
1153 auth_path: None,
1154 config_path: None,
1155 }
1156 }
1157}
1158
1159#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1165#[serde(default)]
1166pub struct GrokbotConfig {
1167 pub enabled: bool,
1170 pub secrets_path: Option<PathBuf>,
1174}
1175
1176fn default_grok_binary() -> PathBuf {
1177 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1178 let grok_home = std::env::var_os("GROK_HOME")
1179 .filter(|value| !value.is_empty())
1180 .map(PathBuf::from)
1181 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1182 grok_home
1183 .map(|home| home.join("bin").join(executable))
1184 .unwrap_or_else(|| PathBuf::from(executable))
1185}
1186
1187#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1195#[serde(default)]
1196pub struct AntigravityConfig {
1197 pub enabled: bool,
1198 pub oauth_client_id: Option<String>,
1200 pub oauth_client_secret: Option<String>,
1204}
1205
1206#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1216#[serde(default)]
1217pub struct CursorConfig {
1218 pub enabled: bool,
1219 pub db_path: Option<PathBuf>,
1223 pub agent_auth_path: Option<PathBuf>,
1228}
1229
1230#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1239#[serde(default)]
1240pub struct KiroConfig {
1241 pub enabled: bool,
1242 pub db_path: Option<PathBuf>,
1246}
1247
1248#[derive(Debug, Clone, Deserialize, Serialize)]
1249#[serde(default)]
1250pub struct AnthropicApiConfig {
1251 pub enabled: bool,
1252 pub api_key_env: String,
1255 pub api_key: Option<String>,
1256 pub monthly_limit: Option<f64>,
1259}
1260
1261impl Default for AnthropicApiConfig {
1262 fn default() -> Self {
1263 Self {
1265 enabled: false,
1266 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1267 api_key: None,
1268 monthly_limit: None,
1269 }
1270 }
1271}
1272
1273#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1283#[serde(default, remote = "Self")]
1284pub struct CustomProviderConfig {
1285 pub id: String,
1287 pub name: String,
1289 pub short_name: String,
1292 pub brand: Option<String>,
1295 pub enabled: bool,
1296 pub url: String,
1298 pub allow_http: bool,
1299 pub api_key_env: String,
1301 pub api_key: Option<String>,
1302 pub auth_header: String,
1304 pub auth_scheme: String,
1306 pub headers: BTreeMap<String, String>,
1308 pub plan: Option<String>,
1310 pub plan_path: Option<String>,
1312 pub cache_ttl_secs: u64,
1314 pub metrics: Vec<CustomMetricSpec>,
1315 pub texts: Vec<CustomTextSpec>,
1316}
1317
1318impl Default for CustomProviderConfig {
1319 fn default() -> Self {
1320 Self {
1321 id: String::new(),
1322 name: String::new(),
1323 short_name: String::new(),
1324 brand: None,
1325 enabled: false,
1326 url: String::new(),
1327 allow_http: false,
1328 api_key_env: String::new(),
1329 api_key: None,
1330 auth_header: "Authorization".to_string(),
1331 auth_scheme: "Bearer".to_string(),
1332 headers: BTreeMap::new(),
1333 plan: None,
1334 plan_path: None,
1335 cache_ttl_secs: 60,
1336 metrics: Vec::new(),
1337 texts: Vec::new(),
1338 }
1339 }
1340}
1341
1342impl<'de> Deserialize<'de> for CustomProviderConfig {
1347 fn deserialize<D: serde::Deserializer<'de>>(
1348 deserializer: D,
1349 ) -> std::result::Result<Self, D::Error> {
1350 let mut this = Self::deserialize(deserializer)?;
1351 if this.name.is_empty() {
1352 this.name = this.id.clone();
1353 }
1354 Ok(this)
1355 }
1356}
1357
1358impl Serialize for CustomProviderConfig {
1359 fn serialize<S: serde::Serializer>(
1360 &self,
1361 serializer: S,
1362 ) -> std::result::Result<S::Ok, S::Error> {
1363 Self::serialize(self, serializer)
1364 }
1365}
1366
1367#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1371#[serde(default)]
1372pub struct CustomMetricSpec {
1373 pub label: String,
1374 pub used: Option<String>,
1375 pub limit: Option<String>,
1376 pub percent: Option<String>,
1377 pub resets_at: Option<String>,
1379 pub window_secs: Option<u64>,
1381}
1382
1383#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1385#[serde(default)]
1386pub struct CustomTextSpec {
1387 pub label: String,
1388 pub value: String,
1389}
1390
1391impl CustomProviderConfig {
1392 pub fn section_label(&self) -> String {
1394 format!("[[custom]] id = {:?}", self.id)
1395 }
1396
1397 pub fn resolve_api_key(&self) -> Result<String> {
1400 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1401 return Ok(key);
1402 }
1403 let advice = if self.api_key_env.is_empty() {
1404 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1405 } else {
1406 format!("export {} or set `api_key`", self.api_key_env)
1407 };
1408 Err(AppError::Credentials(format!(
1409 "custom {}: no API key. Either {advice} under {} in {}.",
1410 self.id,
1411 self.section_label(),
1412 config_path_hint()
1413 )))
1414 }
1415
1416 pub fn cache_ttl(&self) -> std::time::Duration {
1417 std::time::Duration::from_secs(self.cache_ttl_secs)
1418 }
1419
1420 fn validate(&self, index: usize) -> Result<()> {
1424 if !is_valid_custom_id(&self.id) {
1425 return Err(AppError::Other(format!(
1426 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1427 index + 1,
1428 self.id
1429 )));
1430 }
1431 let section = self.section_label();
1432 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1433
1434 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1435 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1436 }
1437 let name_len = self.name.chars().count();
1438 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1439 return Err(bad(
1440 "name must be 1 to 48 characters without control characters".into(),
1441 ));
1442 }
1443 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1444 return Err(bad(format!(
1445 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1446 self.short_name
1447 )));
1448 }
1449 if let Some(brand) = &self.brand
1450 && !VendorId::all().iter().any(|v| v.slug() == brand)
1451 {
1452 return Err(bad(format!(
1453 "brand {brand:?} must name a built-in vendor (it borrows that \
1454 vendor's mark); leave it unset to keep the short_name tag"
1455 )));
1456 }
1457 let url = reqwest::Url::parse(&self.url)
1458 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1459 match url.scheme() {
1460 "https" => {}
1461 "http" if self.allow_http => {}
1462 "http" => {
1463 return Err(bad(
1464 "url must use https:// (set allow_http = true to permit http://)".into(),
1465 ));
1466 }
1467 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1468 }
1469 if !url.username().is_empty() || url.password().is_some() {
1470 return Err(bad("url must not carry credentials (user:pass@)".into()));
1471 }
1472 if url.host_str().is_none() {
1473 return Err(bad("url has no host".into()));
1474 }
1475 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1476 return Err(bad(format!(
1477 "api_key_env {:?} is not a valid environment variable name",
1478 self.api_key_env
1479 )));
1480 }
1481 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1482 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1483 return Err(bad(
1484 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1485 ));
1486 }
1487 for (name, value) in &self.headers {
1488 validate_header_name(§ion, "headers", name)?;
1489 if name.eq_ignore_ascii_case(&self.auth_header) {
1490 return Err(bad(format!(
1491 "headers must not repeat auth_header {:?}",
1492 self.auth_header
1493 )));
1494 }
1495 if reqwest::header::HeaderValue::from_str(value).is_err() {
1496 return Err(bad(format!(
1497 "header {name:?} has a value that is not valid in an HTTP header"
1498 )));
1499 }
1500 }
1501 if let Some(plan) = &self.plan {
1502 validate_custom_label(§ion, "plan", plan)?;
1503 }
1504 if let Some(pointer) = &self.plan_path {
1505 validate_pointer(§ion, "plan_path", pointer)?;
1506 }
1507 if !(10..=3600).contains(&self.cache_ttl_secs) {
1508 return Err(bad(format!(
1509 "cache_ttl_secs must be between 10 and 3600, got {}",
1510 self.cache_ttl_secs
1511 )));
1512 }
1513 if self.metrics.is_empty() && self.texts.is_empty() {
1514 return Err(bad(
1515 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1516 ));
1517 }
1518 let mut metric_labels = HashSet::new();
1519 for metric in &self.metrics {
1520 validate_custom_label(§ion, "metric label", &metric.label)?;
1521 if !metric_labels.insert(metric.label.as_str()) {
1522 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1523 }
1524 let pair = (metric.used.is_some(), metric.limit.is_some());
1525 let well_formed = if metric.percent.is_some() {
1526 pair == (false, false)
1527 } else {
1528 pair == (true, true)
1529 };
1530 if !well_formed {
1531 return Err(bad(format!(
1532 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1533 metric.label
1534 )));
1535 }
1536 for (field, pointer) in [
1537 ("used", &metric.used),
1538 ("limit", &metric.limit),
1539 ("percent", &metric.percent),
1540 ("resets_at", &metric.resets_at),
1541 ] {
1542 if let Some(pointer) = pointer {
1543 validate_pointer(§ion, field, pointer)?;
1544 }
1545 }
1546 if let Some(secs) = metric.window_secs
1547 && secs < 60
1548 {
1549 return Err(bad(format!(
1550 "metric {:?} window_secs must be at least 60, got {secs}",
1551 metric.label
1552 )));
1553 }
1554 }
1555 let mut text_labels = HashSet::new();
1556 for text in &self.texts {
1557 validate_custom_label(§ion, "text label", &text.label)?;
1558 if !text_labels.insert(text.label.as_str()) {
1559 return Err(bad(format!("duplicate text label {:?}", text.label)));
1560 }
1561 validate_pointer(§ion, "value", &text.value)?;
1562 }
1563 Ok(())
1564 }
1565}
1566
1567fn is_valid_custom_id(id: &str) -> bool {
1568 let bytes = id.as_bytes();
1569 let Some(&first) = bytes.first() else {
1570 return false;
1571 };
1572 bytes.len() <= 32
1573 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1574 && bytes
1575 .iter()
1576 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1577}
1578
1579fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1580 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1581 return Err(AppError::Other(format!(
1582 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1583 )));
1584 }
1585 Ok(())
1586}
1587
1588fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1589 let len = label.chars().count();
1590 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1591 return Err(AppError::Other(format!(
1592 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1593 )));
1594 }
1595 Ok(())
1596}
1597
1598fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1599 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1600 return Err(AppError::Other(format!(
1601 "{section}: {field} {name:?} is not a valid HTTP header name"
1602 )));
1603 }
1604 Ok(())
1605}
1606
1607pub fn resolve_api_key(
1610 vendor_label: &str,
1611 env_var_name: &str,
1612 inline: Option<&str>,
1613) -> crate::error::Result<String> {
1614 let section = match vendor_label {
1615 "OpenCode Go" => "[opencode-go]".to_string(),
1616 _ => format!("[{}]", vendor_label.to_lowercase()),
1617 };
1618 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1619}
1620
1621pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1625 if is_valid_env_var_name(env_var_name)
1626 && let Ok(v) = std::env::var(env_var_name)
1627 && !v.is_empty()
1628 {
1629 return Some(v);
1630 }
1631 inline.filter(|v| !v.is_empty()).map(str::to_string)
1632}
1633
1634fn resolve_api_key_in_section(
1635 vendor_label: &str,
1636 section: &str,
1637 env_var_name: &str,
1638 inline: Option<&str>,
1639) -> crate::error::Result<String> {
1640 if let Some(key) = optional_api_key(env_var_name, inline) {
1641 return Ok(key);
1642 }
1643 let valid_env_name = is_valid_env_var_name(env_var_name);
1644 let advice = if valid_env_name {
1645 "set an API key in a valid environment variable or set `api_key`"
1646 } else {
1647 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1648 };
1649 Err(crate::error::AppError::Credentials(format!(
1650 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1651 config_path_hint()
1652 )))
1653}
1654
1655pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1656 let mut chars = name.chars();
1657 let Some(first) = chars.next() else {
1658 return false;
1659 };
1660 (first.is_ascii_alphabetic() || first == '_')
1661 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1662}
1663
1664impl Config {
1665 pub fn load() -> Result<Self> {
1668 let Some(path) = resolved_path() else {
1669 return Ok(Self::default());
1670 };
1671 Self::load_from(&path)
1672 }
1673
1674 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1675 match std::fs::read_to_string(path) {
1676 Ok(s) => {
1677 let mut config: Self = toml::from_str(&s)?;
1678 config.expand_paths();
1682 config.validate()?;
1683 #[cfg(unix)]
1684 config.protect_inline_secrets(path)?;
1685 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1689 Ok(config)
1690 }
1691 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1692 Err(e) => Err(AppError::io_at(path, e)),
1693 }
1694 }
1695
1696 fn expand_paths(&mut self) {
1697 expand_tilde_opt(&mut self.context.projects_path);
1698 expand_tilde_opt(&mut self.anthropic.credentials_path);
1699 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1700 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1701 expand_tilde_opt(&mut self.openai.codex_auth_path);
1702 expand_tilde_opt(&mut self.cursor.db_path);
1703 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1704 expand_tilde_opt(&mut self.kiro.db_path);
1705 expand_tilde_opt(&mut self.kimi.credentials_path);
1706 expand_tilde_opt(&mut self.grokbot.secrets_path);
1707 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1708 expand_tilde_opt(&mut self.supergrok.auth_path);
1709 expand_tilde_opt(&mut self.supergrok.config_path);
1710 for account in &mut self.anthropic.accounts {
1711 account.credentials_path = expand_tilde(&account.credentials_path);
1712 }
1713 for account in &mut self.openai.accounts {
1714 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1715 }
1716 }
1717
1718 #[cfg(unix)]
1722 fn has_inline_secrets(&self) -> bool {
1723 [
1724 self.zai.api_key.as_deref(),
1725 self.openrouter.api_key.as_deref(),
1726 self.deepseek.api_key.as_deref(),
1727 self.kimi.api_key.as_deref(),
1728 self.kilo.api_key.as_deref(),
1729 self.novita.api_key.as_deref(),
1730 self.minimax.api_key.as_deref(),
1731 self.moonshot.api_key.as_deref(),
1732 self.grok.api_key.as_deref(),
1733 self.anthropic_api.api_key.as_deref(),
1734 self.opencode_go.api_key.as_deref(),
1735 self.antigravity.oauth_client_secret.as_deref(),
1736 ]
1737 .into_iter()
1738 .chain(
1739 self.openrouter
1740 .accounts
1741 .iter()
1742 .map(|account| account.api_key.as_deref()),
1743 )
1744 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
1745 .any(|key| key.is_some_and(|key| !key.is_empty()))
1746 }
1747
1748 fn custom_secret_env_vars(&self) -> Vec<String> {
1749 self.custom
1750 .iter()
1751 .filter(|c| !c.api_key_env.is_empty())
1752 .map(|c| c.api_key_env.clone())
1753 .collect()
1754 }
1755
1756 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
1758 self.custom.iter().filter(|c| c.enabled)
1759 }
1760
1761 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
1763 self.custom.iter().find(|c| c.id == id)
1764 }
1765
1766 #[cfg(unix)]
1767 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1768 if !self.has_inline_secrets() {
1769 return Ok(());
1770 }
1771
1772 let metadata = std::fs::metadata(path).map_err(|_| {
1773 AppError::Credentials(format!(
1774 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1775 path.display()
1776 ))
1777 })?;
1778 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1779 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1780 AppError::Credentials(format!(
1781 "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",
1782 path.display()
1783 ))
1784 })?;
1785 }
1786 Ok(())
1787 }
1788
1789 pub fn is_enabled(&self, id: VendorId) -> bool {
1790 match id {
1791 VendorId::Anthropic => self.anthropic.enabled,
1792 VendorId::AnthropicApi => self.anthropic_api.enabled,
1793 VendorId::Openai => self.openai.enabled,
1794 VendorId::Copilot => self.copilot.enabled,
1795 VendorId::Zai => self.zai.enabled,
1796 VendorId::Openrouter => self.openrouter.enabled,
1797 VendorId::Deepseek => self.deepseek.enabled,
1798 VendorId::Kimi => self.kimi.enabled,
1799 VendorId::Kilo => self.kilo.enabled,
1800 VendorId::Novita => self.novita.enabled,
1801 VendorId::Moonshot => self.moonshot.enabled,
1802 VendorId::Grok => self.grok.enabled,
1803 VendorId::Supergrok => self.supergrok.enabled,
1804 VendorId::Grokbot => self.grokbot.enabled,
1805 VendorId::Antigravity => self.antigravity.enabled,
1806 VendorId::Cursor => self.cursor.enabled,
1807 VendorId::Minimax => self.minimax.enabled,
1808 VendorId::Kiro => self.kiro.enabled,
1809 VendorId::NousResearch => self.nous.enabled,
1810 VendorId::OpenCodeGo => self.opencode_go.enabled,
1811 VendorId::CommandCode => self.commandcode.enabled,
1812 VendorId::Ollama => self.ollama.enabled,
1813 }
1814 }
1815
1816 pub fn api_key_env_for(&self, id: VendorId) -> &str {
1823 match id {
1824 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1825 VendorId::Zai => &self.zai.api_key_env,
1826 VendorId::Openrouter => &self.openrouter.api_key_env,
1827 VendorId::Deepseek => &self.deepseek.api_key_env,
1828 VendorId::Kimi => &self.kimi.api_key_env,
1829 VendorId::Kilo => &self.kilo.api_key_env,
1830 VendorId::Novita => &self.novita.api_key_env,
1831 VendorId::Moonshot => &self.moonshot.api_key_env,
1832 VendorId::Grok => &self.grok.api_key_env,
1833 VendorId::Minimax => &self.minimax.api_key_env,
1834 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1835 VendorId::Ollama => &self.ollama.api_key_env,
1836 VendorId::Anthropic
1839 | VendorId::Openai
1840 | VendorId::Copilot
1841 | VendorId::Supergrok
1842 | VendorId::Grokbot
1843 | VendorId::Antigravity
1844 | VendorId::Cursor
1845 | VendorId::Kiro
1846 | VendorId::NousResearch
1847 | VendorId::CommandCode => id.api_key_env(),
1848 }
1849 }
1850
1851 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1855 let raw = match id {
1856 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1857 VendorId::Zai => self.zai.api_key.as_deref(),
1858 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1859 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1860 VendorId::Kimi => self.kimi.api_key.as_deref(),
1861 VendorId::Kilo => self.kilo.api_key.as_deref(),
1862 VendorId::Novita => self.novita.api_key.as_deref(),
1863 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1864 VendorId::Grok => self.grok.api_key.as_deref(),
1865 VendorId::Minimax => self.minimax.api_key.as_deref(),
1866 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1867 VendorId::Ollama => self.ollama.api_key.as_deref(),
1868 VendorId::Anthropic
1869 | VendorId::Openai
1870 | VendorId::Copilot
1871 | VendorId::Supergrok
1872 | VendorId::Grokbot
1873 | VendorId::Antigravity
1874 | VendorId::Cursor
1875 | VendorId::Kiro
1876 | VendorId::NousResearch
1877 | VendorId::CommandCode => None,
1878 };
1879 raw.filter(|key| !key.is_empty())
1880 }
1881
1882 pub fn enabled_vendors(&self) -> Vec<VendorId> {
1883 VendorId::all()
1884 .iter()
1885 .copied()
1886 .filter(|id| self.is_enabled(*id))
1887 .collect()
1888 }
1889
1890 pub fn validate(&self) -> Result<()> {
1894 if let Some(minutes) = self.tray.refresh_minutes
1895 && !TRAY_REFRESH_MINUTES.contains(&minutes)
1896 {
1897 return Err(AppError::Other(format!(
1898 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
1899 )));
1900 }
1901 if self.context.context_window_tokens == Some(0) {
1902 return Err(AppError::Other(
1903 "[context] context_window_tokens must be greater than zero".into(),
1904 ));
1905 }
1906 for (model, tokens) in &self.context.model_context_window_tokens {
1907 if model.trim().is_empty() {
1908 return Err(AppError::Other(
1909 "[context] model_context_window_tokens keys must not be empty".into(),
1910 ));
1911 }
1912 if *tokens == 0 {
1913 return Err(AppError::Other(format!(
1914 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1915 )));
1916 }
1917 }
1918 if let Some(limit) = self.anthropic_api.monthly_limit
1919 && (!limit.is_finite() || limit <= 0.0)
1920 {
1921 return Err(AppError::Other(
1922 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1923 remove it to show spend without a limit"
1924 .into(),
1925 ));
1926 }
1927 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1928 && !self.kimi.region.eq_ignore_ascii_case("auto")
1929 {
1930 return Err(AppError::Other(format!(
1931 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1932 self.kimi.region
1933 )));
1934 }
1935 if !self.minimax.region.eq_ignore_ascii_case("global")
1936 && !self.minimax.region.eq_ignore_ascii_case("cn")
1937 {
1938 return Err(AppError::Other(format!(
1939 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1940 self.minimax.region
1941 )));
1942 }
1943 if self.supergrok.grok_binary.as_os_str().is_empty() {
1944 return Err(AppError::Other(
1945 "[supergrok] grok_binary must not be empty".into(),
1946 ));
1947 }
1948 let mut labels = HashSet::new();
1949 for account in &self.anthropic.accounts {
1950 validate_account_label(&account.label)?;
1951 if !labels.insert(&account.label) {
1952 return Err(AppError::Credentials(format!(
1953 "duplicate anthropic account label {:?}",
1954 account.label
1955 )));
1956 }
1957 }
1958 let mut openai_labels = HashSet::new();
1959 for account in &self.openai.accounts {
1960 validate_account_label_for("openai", &account.label)?;
1961 if !openai_labels.insert(&account.label) {
1962 return Err(AppError::Credentials(format!(
1963 "duplicate openai account label {:?}",
1964 account.label
1965 )));
1966 }
1967 }
1968 let mut openrouter_labels = HashSet::new();
1969 for account in &self.openrouter.accounts {
1970 validate_account_label_for("openrouter", &account.label)?;
1971 if !openrouter_labels.insert(&account.label) {
1972 return Err(AppError::Credentials(format!(
1973 "duplicate openrouter account label {:?}",
1974 account.label
1975 )));
1976 }
1977 let has_env = account
1978 .api_key_env
1979 .as_deref()
1980 .is_some_and(|name| !name.is_empty());
1981 let has_inline = account
1982 .api_key
1983 .as_deref()
1984 .is_some_and(|key| !key.is_empty());
1985 if !has_env && !has_inline {
1986 return Err(AppError::Credentials(format!(
1987 "openrouter account {:?} must set api_key_env or api_key",
1988 account.label
1989 )));
1990 }
1991 }
1992 self.validate_custom()
1993 }
1994
1995 fn validate_custom(&self) -> Result<()> {
1999 let mut ids = HashSet::new();
2000 let mut short_names: HashSet<&str> =
2001 VendorId::all().iter().map(|v| v.short_name()).collect();
2002 for (index, custom) in self.custom.iter().enumerate() {
2003 custom.validate(index)?;
2004 if !ids.insert(custom.id.as_str()) {
2005 return Err(AppError::Other(format!(
2006 "{}: duplicate id",
2007 custom.section_label()
2008 )));
2009 }
2010 if !short_names.insert(custom.short_name.as_str()) {
2011 return Err(AppError::Other(format!(
2012 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2013 custom.section_label(),
2014 custom.short_name
2015 )));
2016 }
2017 }
2018 Ok(())
2019 }
2020}
2021
2022#[cfg(unix)]
2023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2024enum InlineKeyPermissionDecision {
2025 Ok,
2026 Tighten,
2027}
2028
2029#[cfg(unix)]
2030fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2031 if mode & 0o077 == 0 {
2032 InlineKeyPermissionDecision::Ok
2033 } else {
2034 InlineKeyPermissionDecision::Tighten
2035 }
2036}
2037
2038pub fn default_path() -> Option<PathBuf> {
2039 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2040 Some(proj.config_dir().join("config.toml"))
2041}
2042
2043fn legacy_xdg_path() -> Option<PathBuf> {
2048 let home = crate::cache::home_dir().ok()?;
2049 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2050}
2051
2052pub fn resolved_path() -> Option<PathBuf> {
2063 if let Some(path) = override_path() {
2064 return Some(path);
2065 }
2066 let canonical = default_path();
2067 if let Some(p) = &canonical
2068 && p.exists()
2069 {
2070 return canonical;
2071 }
2072 if let Some(legacy) = legacy_xdg_path()
2073 && legacy.exists()
2074 {
2075 return Some(legacy);
2076 }
2077 canonical
2078}
2079
2080static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2081
2082pub fn set_override_path(path: &std::path::Path) {
2088 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2089 *slot = Some(path.to_path_buf());
2090 }
2091}
2092
2093#[doc(hidden)]
2096pub fn clear_override_path() {
2097 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2098 *slot = None;
2099 }
2100}
2101
2102fn override_path() -> Option<PathBuf> {
2103 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2104}
2105
2106#[doc(hidden)]
2112pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2113 #[cfg(unix)]
2114 {
2115 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2116 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2117 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2118 }
2119 #[cfg(windows)]
2120 {
2121 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2122 const PREFIX: &[u16] = &[
2123 b'-' as u16,
2124 b'-' as u16,
2125 b'c' as u16,
2126 b'o' as u16,
2127 b'n' as u16,
2128 b'f' as u16,
2129 b'i' as u16,
2130 b'g' as u16,
2131 b'=' as u16,
2132 ];
2133 let wide: Vec<u16> = arg.encode_wide().collect();
2134 let rest = wide.strip_prefix(PREFIX)?;
2135 Some(std::ffi::OsString::from_wide(rest).into())
2136 }
2137 #[cfg(not(any(unix, windows)))]
2138 {
2139 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2140 }
2141}
2142
2143fn expand_tilde(p: &std::path::Path) -> PathBuf {
2146 let Some(s) = p.to_str() else {
2147 return p.to_path_buf();
2148 };
2149 let rest = if s == "~" {
2150 ""
2151 } else if let Some(r) = s.strip_prefix("~/") {
2152 r
2153 } else {
2154 return p.to_path_buf();
2155 };
2156 match crate::cache::home_dir() {
2157 Ok(home) if rest.is_empty() => home,
2158 Ok(home) => home.join(rest),
2159 Err(_) => p.to_path_buf(),
2160 }
2161}
2162
2163fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2164 if let Some(inner) = p.as_ref() {
2165 *p = Some(expand_tilde(inner));
2166 }
2167}
2168
2169pub fn config_path_hint() -> String {
2174 resolved_path()
2175 .map(|p| p.display().to_string())
2176 .unwrap_or_else(|| "config.toml".to_string())
2177}
2178
2179#[cfg(test)]
2180mod tests {
2181 use super::*;
2182 use std::io::Write;
2183 use tempfile::NamedTempFile;
2184
2185 #[cfg(unix)]
2186 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2187
2188 fn write_toml(s: &str) -> NamedTempFile {
2189 let mut f = NamedTempFile::new().unwrap();
2190 f.write_all(s.as_bytes()).unwrap();
2191 f.flush().unwrap();
2192 f
2193 }
2194
2195 #[test]
2199 fn openai_without_accounts_resolves_the_singular_path() {
2200 let explicit = OpenAiConfig {
2201 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2202 ..OpenAiConfig::default()
2203 };
2204 assert_eq!(
2205 explicit.resolve_auth_path(None).unwrap(),
2206 PathBuf::from("/tmp/codex/auth.json")
2207 );
2208
2209 let bare = OpenAiConfig::default();
2210 assert_eq!(
2211 bare.resolve_auth_path(None).unwrap(),
2212 crate::openai::creds::default_path().unwrap(),
2213 "no codex_auth_path must still mean ~/.codex/auth.json"
2214 );
2215 }
2216
2217 #[test]
2220 fn openai_named_accounts_resolve_their_own_auth_file() {
2221 let config: Config = toml::from_str(
2222 r#"
2223 [openai]
2224 codex_auth_path = "/tmp/personal/auth.json"
2225 [[openai.accounts]]
2226 label = "work"
2227 codex_auth_path = "/tmp/work/auth.json"
2228 "#,
2229 )
2230 .unwrap();
2231
2232 assert_eq!(
2233 config.openai.resolve_auth_path(Some("work")).unwrap(),
2234 PathBuf::from("/tmp/work/auth.json")
2235 );
2236 assert_eq!(
2237 config.openai.resolve_auth_path(None).unwrap(),
2238 PathBuf::from("/tmp/personal/auth.json")
2239 );
2240 }
2241
2242 #[test]
2245 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2246 let config = OpenAiConfig {
2247 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2248 accounts: vec![OpenAiAccount {
2249 label: "work".into(),
2250 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2251 }],
2252 ..OpenAiConfig::default()
2253 };
2254 let err = config
2255 .resolve_auth_path(Some("nope"))
2256 .unwrap_err()
2257 .to_string();
2258 assert!(err.contains("nope"), "{err}");
2259 assert!(err.contains("[[openai.accounts]]"), "{err}");
2260 }
2261
2262 #[test]
2263 fn defaults_enable_only_the_four_core_vendors() {
2264 let c = Config::default();
2265 assert!(c.is_enabled(VendorId::Anthropic));
2266 assert!(c.is_enabled(VendorId::Openai));
2267 assert!(c.is_enabled(VendorId::Zai));
2268 assert!(c.is_enabled(VendorId::Openrouter));
2269 for opt_in in [
2270 VendorId::AnthropicApi,
2271 VendorId::Copilot,
2272 VendorId::Deepseek,
2273 VendorId::Kimi,
2274 VendorId::Kilo,
2275 VendorId::Novita,
2276 VendorId::Moonshot,
2277 VendorId::Grok,
2278 VendorId::Supergrok,
2279 VendorId::Grokbot,
2280 VendorId::Cursor,
2281 VendorId::Minimax,
2282 VendorId::Kiro,
2283 ] {
2284 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2285 }
2286 assert_eq!(c.enabled_vendors().len(), 4);
2287 }
2288
2289 #[test]
2290 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2291 let config = Config::default();
2292 assert!(!config.is_enabled(VendorId::NousResearch));
2293 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2294 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2295 assert!(config.opencode_go.api_key.is_none());
2296 assert!(!config.is_enabled(VendorId::Copilot));
2297 }
2298
2299 #[cfg(unix)]
2300 #[test]
2301 fn inline_credentials_are_protected() {
2302 let mut config = Config::default();
2303 config.opencode_go.api_key = Some("<redacted>".to_string());
2304 assert!(config.has_inline_secrets());
2305 }
2306
2307 #[test]
2308 fn antigravity_oauth_client_overrides_parse() {
2309 let config: Config = toml::from_str(
2310 "[antigravity]
2311enabled = true
2312oauth_client_id = \"test-client\"
2313oauth_client_secret = \"test-client-secret\"
2314",
2315 )
2316 .unwrap();
2317 assert!(config.antigravity.enabled);
2318 assert_eq!(
2319 config.antigravity.oauth_client_id.as_deref(),
2320 Some("test-client")
2321 );
2322 assert_eq!(
2323 config.antigravity.oauth_client_secret.as_deref(),
2324 Some("test-client-secret")
2325 );
2326 let bare: Config = toml::from_str(
2327 "[antigravity]
2328enabled = true
2329",
2330 )
2331 .unwrap();
2332 assert!(bare.antigravity.oauth_client_id.is_none());
2333 assert!(bare.antigravity.oauth_client_secret.is_none());
2334 }
2335
2336 #[cfg(unix)]
2337 #[test]
2338 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2339 let mut config = Config::default();
2340 config.antigravity.oauth_client_id = Some("test-client".into());
2341 assert!(!config.has_inline_secrets());
2342 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2343 assert!(config.has_inline_secrets());
2344 }
2345
2346 #[cfg(unix)]
2347 #[test]
2348 fn openrouter_named_inline_keys_receive_config_file_protection() {
2349 let mut config = Config::default();
2350 config.openrouter.accounts.push(OpenRouterAccount {
2351 label: "work".into(),
2352 api_key_env: None,
2353 api_key: Some("<redacted>".into()),
2354 });
2355 assert!(config.has_inline_secrets());
2356 }
2357
2358 #[test]
2359 fn missing_file_uses_defaults() {
2360 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2361 let c = Config::load_from(path).unwrap();
2362 assert!(c.is_enabled(VendorId::Anthropic));
2363 }
2364
2365 #[test]
2366 fn parses_full_config() {
2367 let f = write_toml(
2368 r#"
2369 [anthropic]
2370 enabled = true
2371
2372 [openai]
2373 enabled = false
2374 admin_key_env = "MY_ADMIN_KEY"
2375
2376 [zai]
2377 enabled = true
2378 api_key_env = "MY_ZAI"
2379 plan_tier = "pro"
2380
2381 [openrouter]
2382 enabled = false
2383 "#,
2384 );
2385 let c = Config::load_from(f.path()).unwrap();
2386 assert!(c.is_enabled(VendorId::Anthropic));
2387 assert!(!c.is_enabled(VendorId::Openai));
2388 assert!(c.is_enabled(VendorId::Zai));
2389 assert!(!c.is_enabled(VendorId::Openrouter));
2390 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2391 assert_eq!(c.zai.api_key_env, "MY_ZAI");
2392 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2393 assert!(c.openrouter.accounts.is_empty());
2394 assert!(c.openrouter.show_default_account);
2395 }
2396
2397 #[test]
2398 fn partial_config_falls_back_to_defaults() {
2399 let f = write_toml(
2400 r#"[openai]
2401enabled = false
2402"#,
2403 );
2404 let c = Config::load_from(f.path()).unwrap();
2405 assert!(!c.is_enabled(VendorId::Openai));
2406 assert!(c.is_enabled(VendorId::Anthropic));
2408 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2409 }
2410
2411 #[test]
2412 fn malformed_toml_returns_error() {
2413 let f = write_toml("this is not = = valid");
2414 assert!(Config::load_from(f.path()).is_err());
2415 }
2416
2417 #[cfg(unix)]
2418 #[test]
2419 fn load_from_tightens_world_readable_config_with_inline_api_key() {
2420 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2421 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2422
2423 Config::load_from(file.path()).unwrap();
2424
2425 assert_eq!(
2426 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2427 0o600
2428 );
2429 }
2430
2431 #[cfg(unix)]
2432 #[test]
2433 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2434 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2435 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2436
2437 Config::load_from(file.path()).unwrap();
2438
2439 assert_eq!(
2440 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2441 0o644
2442 );
2443 }
2444
2445 #[cfg(unix)]
2446 #[test]
2447 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2448 assert_eq!(
2449 inline_key_permission_decision(0o600),
2450 InlineKeyPermissionDecision::Ok
2451 );
2452 assert_eq!(
2453 inline_key_permission_decision(0o640),
2454 InlineKeyPermissionDecision::Tighten
2455 );
2456 assert_eq!(
2457 inline_key_permission_decision(0o604),
2458 InlineKeyPermissionDecision::Tighten
2459 );
2460 }
2461
2462 #[test]
2463 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2464 for value in ["0", "-1", "inf", "nan"] {
2465 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2466 let error = Config::load_from(file.path()).unwrap_err().to_string();
2467 assert!(error.contains("monthly_limit"), "value {value}: {error}");
2468 }
2469
2470 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2471 assert_eq!(
2472 Config::load_from(file.path())
2473 .unwrap()
2474 .anthropic_api
2475 .monthly_limit,
2476 Some(1000.0)
2477 );
2478 }
2479
2480 #[test]
2481 fn minimax_region_accepts_only_known_instances() {
2482 for region in ["global", "GLOBAL", "cn", "CN"] {
2483 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2484 assert_eq!(
2485 Config::load_from(file.path()).unwrap().minimax.region,
2486 region
2487 );
2488 }
2489
2490 for region in ["", "china", "us"] {
2491 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2492 let error = Config::load_from(file.path()).unwrap_err().to_string();
2493 assert!(error.contains("[minimax] region"), "{error}");
2494 }
2495 }
2496
2497 #[test]
2498 fn kimi_region_accepts_auto_and_both_deployments() {
2499 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
2500 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2501 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
2502 }
2503
2504 for region in ["", "us", "oversea"] {
2505 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2506 let error = Config::load_from(file.path()).unwrap_err().to_string();
2507 assert!(error.contains("[kimi] region"), "{error}");
2508 }
2509 }
2510
2511 #[test]
2512 fn kimi_defaults_to_auto_region_and_no_credential_override() {
2513 let defaults = KimiConfig::default();
2514 assert_eq!(defaults.region, "auto");
2515 assert_eq!(defaults.credentials_path, None);
2516 assert!(!defaults.enabled);
2517 }
2518
2519 #[test]
2520 fn kimi_credentials_path_expands_a_tilde() {
2521 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
2522 let path = Config::load_from(file.path())
2523 .unwrap()
2524 .kimi
2525 .credentials_path
2526 .unwrap();
2527 assert!(!path.starts_with("~"), "{}", path.display());
2528 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
2529 }
2530
2531 #[test]
2532 fn grokbot_is_opt_in_and_takes_no_api_key() {
2533 let defaults = GrokbotConfig::default();
2534 assert!(!defaults.enabled);
2535 assert_eq!(defaults.secrets_path, None);
2536 let config = Config::default();
2538 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
2539 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
2540
2541 let file = write_toml("[grokbot]\nenabled = true\n");
2542 let config = Config::load_from(file.path()).unwrap();
2543 assert!(config.is_enabled(VendorId::Grokbot));
2544 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
2545 }
2546
2547 #[test]
2548 fn grokbot_secrets_path_expands_a_tilde() {
2549 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
2550 let path = Config::load_from(file.path())
2551 .unwrap()
2552 .grokbot
2553 .secrets_path
2554 .unwrap();
2555 assert!(!path.starts_with("~"), "{}", path.display());
2556 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
2557 }
2558
2559 #[test]
2560 fn optional_api_key_reports_absence_instead_of_failing() {
2561 assert_eq!(
2562 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
2563 Some("inline".to_string())
2564 );
2565 assert_eq!(
2566 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
2567 None
2568 );
2569 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
2570 assert_eq!(
2573 optional_api_key("9INVALID", Some("inline")),
2574 Some("inline".to_string())
2575 );
2576 }
2577
2578 #[test]
2579 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
2580 let defaults = Config::default();
2581 assert!(!defaults.context.enabled);
2582 assert_eq!(
2583 defaults.context.window_tokens_for(Some("claude-test")),
2584 None
2585 );
2586
2587 let file = write_toml(
2588 r#"
2589 [context]
2590 enabled = true
2591 context_window_tokens = 200000
2592
2593 [context.model_context_window_tokens]
2594 claude-opus-1m = 1000000
2595 "claude exact id" = 300000
2596 "#,
2597 );
2598 let config = Config::load_from(file.path()).unwrap();
2599 assert!(config.context.enabled);
2600 assert_eq!(
2601 config.context.window_tokens_for(Some("claude-opus-1m")),
2602 Some(1_000_000)
2603 );
2604 assert_eq!(
2605 config.context.window_tokens_for(Some("claude exact id")),
2606 Some(300_000)
2607 );
2608 assert_eq!(
2609 config.context.window_tokens_for(Some("another-model")),
2610 Some(200_000)
2611 );
2612 }
2613
2614 #[test]
2615 fn context_layout_defaults_to_full_and_parses_each_variant() {
2616 assert_eq!(Config::default().context.layout, ContextLayout::Full);
2617 for (text, want) in [
2618 ("full", ContextLayout::Full),
2619 ("split", ContextLayout::Split),
2620 ("bottom", ContextLayout::Bottom),
2621 ] {
2622 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
2623 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
2624 }
2625 let file = write_toml("[context]\nlayout = \"floating\"\n");
2626 assert!(
2627 Config::load_from(file.path()).is_err(),
2628 "an unknown layout must be rejected, not silently defaulted"
2629 );
2630 }
2631
2632 #[test]
2633 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
2634 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
2635 for (text, want) in [
2636 ("sidebar", VendorBoxStyle::Sidebar),
2637 ("navbar", VendorBoxStyle::Navbar),
2638 ("none", VendorBoxStyle::None),
2639 ] {
2640 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
2641 assert_eq!(
2642 Config::load_from(file.path()).unwrap().ui.vendor_box(),
2643 want
2644 );
2645 }
2646 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
2647 assert!(
2648 Config::load_from(file.path()).is_err(),
2649 "an unknown vendor_box style must be rejected, not silently defaulted"
2650 );
2651 }
2652
2653 #[test]
2654 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
2655 for source in [
2656 "[context]\ncontext_window_tokens = 0\n",
2657 "[context.model_context_window_tokens]\nclaude = 0\n",
2658 "[context.model_context_window_tokens]\n\" \" = 200000\n",
2659 ] {
2660 let file = write_toml(source);
2661 let error = Config::load_from(file.path()).unwrap_err().to_string();
2662 assert!(error.contains("context"), "{error}");
2663 }
2664 }
2665
2666 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2668 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2669 M.lock().unwrap_or_else(|p| p.into_inner())
2670 }
2671
2672 #[test]
2673 fn resolve_api_key_prefers_env_over_inline() {
2674 let _g = env_guard();
2675 let var = "AI_USAGEBAR_TEST_ENV_WINS";
2677 unsafe { std::env::set_var(var, "from-env") };
2679 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
2680 unsafe { std::env::remove_var(var) };
2681 assert_eq!(got, "from-env");
2682 }
2683
2684 #[test]
2685 fn resolve_api_key_falls_back_to_inline() {
2686 let _g = env_guard();
2687 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
2688 unsafe { std::env::remove_var(var) };
2689 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
2690 assert_eq!(got, "inline-key");
2691 }
2692
2693 #[test]
2694 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
2695 struct NeverRun;
2696 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
2697 fn run(
2698 &self,
2699 _: &crate::copilot::credentials::GhAuthTokenCommand,
2700 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2701 panic!("environment override must not invoke gh")
2702 }
2703 }
2704
2705 let token = CopilotConfig::default()
2706 .resolve_token_with(
2707 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
2708 &NeverRun,
2709 )
2710 .unwrap();
2711 assert_eq!(token, "from-environment");
2712 }
2713
2714 #[test]
2715 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
2716 struct FailedGh;
2717 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
2718 fn run(
2719 &self,
2720 _: &crate::copilot::credentials::GhAuthTokenCommand,
2721 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2722 Ok(crate::copilot::credentials::GhAuthTokenOutput {
2723 success: false,
2724 stdout: b"never-echo-gh-output".to_vec(),
2725 })
2726 }
2727 }
2728 let error = CopilotConfig::default()
2729 .resolve_token_with(|_| None, &FailedGh)
2730 .unwrap_err()
2731 .to_string();
2732 assert!(error.contains("gh auth login --web"));
2733 assert!(!error.contains("never-echo-gh-output"));
2734 }
2735
2736 #[test]
2737 fn resolve_api_key_errors_when_both_missing() {
2738 let _g = env_guard();
2739 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
2740 unsafe { std::env::remove_var(var) };
2741 let err = resolve_api_key("Zai", var, None).unwrap_err();
2742 match err {
2743 crate::error::AppError::Credentials(msg) => {
2744 assert!(
2745 msg.contains("api_key"),
2746 "error should suggest config field: {msg}"
2747 );
2748 }
2749 other => panic!("expected Credentials error, got {other:?}"),
2750 }
2751 }
2752
2753 #[test]
2754 fn resolve_api_key_uses_exact_opencode_go_section_name() {
2755 let _g = env_guard();
2756 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
2757 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
2758 let message = err.to_string();
2759 assert!(
2760 message.contains("[opencode-go]"),
2761 "wrong section hint: {message}"
2762 );
2763 assert!(
2764 !message.contains("[opencode go]"),
2765 "wrong section hint: {message}"
2766 );
2767 }
2768
2769 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
2770 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2771 M.lock().unwrap_or_else(|p| p.into_inner())
2772 }
2773
2774 struct ScopedPathOverride {
2780 _serial: std::sync::MutexGuard<'static, ()>,
2781 }
2782
2783 impl Drop for ScopedPathOverride {
2784 fn drop(&mut self) {
2785 clear_override_path();
2786 }
2787 }
2788
2789 fn scoped_path_override() -> ScopedPathOverride {
2790 ScopedPathOverride {
2791 _serial: path_override_guard(),
2792 }
2793 }
2794
2795 #[test]
2796 fn override_path_wins_over_canonical_and_legacy() {
2797 let _scoped = scoped_path_override();
2798 let file = NamedTempFile::new().unwrap();
2799 set_override_path(file.path());
2800 assert_eq!(resolved_path().as_deref(), Some(file.path()));
2801 assert_eq!(config_path_hint(), file.path().display().to_string());
2802 clear_override_path();
2803 let p = resolved_path().expect("a config path must resolve");
2805 assert!(p.ends_with("config.toml"));
2806 }
2807
2808 #[test]
2809 fn scoped_override_guard_clears_the_override_on_panic() {
2810 let hook = std::panic::take_hook();
2813 std::panic::set_hook(Box::new(|_| {}));
2814 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2815 let _scoped = scoped_path_override();
2816 set_override_path(std::path::Path::new("panicked-override.toml"));
2817 panic!("simulated mid-test failure");
2818 }))
2819 .is_err();
2820 std::panic::set_hook(hook);
2821 assert!(panicked, "the simulated failure must run");
2822 let _serial = path_override_guard();
2823 assert!(
2824 override_path().is_none(),
2825 "a panicking test must not leak the override into siblings"
2826 );
2827 }
2828
2829 #[test]
2830 fn config_path_hint_ends_with_config_toml() {
2831 let _g = path_override_guard();
2832 assert!(config_path_hint().ends_with("config.toml"));
2835 }
2836
2837 #[test]
2838 fn config_flag_value_splits_the_equals_form() {
2839 use std::ffi::OsStr;
2840 assert_eq!(
2841 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
2842 Some(std::path::Path::new("work.toml"))
2843 );
2844 assert_eq!(
2845 config_flag_value(OsStr::new("--config=")).as_deref(),
2846 Some(std::path::Path::new(""))
2847 );
2848 assert_eq!(config_flag_value(OsStr::new("--config")), None);
2849 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
2850 assert_eq!(config_flag_value(OsStr::new("account")), None);
2851 }
2852
2853 #[cfg(unix)]
2857 #[test]
2858 fn config_flag_value_keeps_undecodable_bytes_intact() {
2859 use std::ffi::OsString;
2860 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2861 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
2862 let value = config_flag_value(&raw).expect("prefix matches");
2863 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
2864 }
2865
2866 #[cfg(windows)]
2867 #[test]
2868 fn config_flag_value_keeps_lone_surrogates_intact() {
2869 use std::ffi::OsString;
2870 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2871 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
2872 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
2874 let raw = OsString::from_wide(&wide);
2875 let value = config_flag_value(&raw).expect("prefix matches");
2876 let mut expected = vec![0xDC00u16];
2877 expected.extend("x.toml".encode_utf16());
2878 assert_eq!(
2879 value.as_os_str().encode_wide().collect::<Vec<_>>(),
2880 expected
2881 );
2882 }
2883
2884 #[test]
2885 fn resolve_api_key_treats_empty_env_as_unset() {
2886 let _g = env_guard();
2887 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
2888 unsafe { std::env::set_var(var, "") };
2889 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
2890 unsafe { std::env::remove_var(var) };
2891 assert_eq!(got, "inline");
2892 }
2893
2894 #[test]
2895 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
2896 let _g = env_guard();
2897 let bad = "sk-kimi-very-real-looking-pasted-secret";
2899 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
2900 let msg = err.to_string();
2901 assert!(
2902 msg.contains("invalid") && msg.contains("api_key_env"),
2903 "error should explain misconfiguration: {msg}"
2904 );
2905 assert!(
2906 !msg.contains(bad),
2907 "error must not echo the misconfigured value: {msg}"
2908 );
2909 assert!(msg.contains("valid environment variable name"));
2910 assert!(
2911 msg.contains("[kimi]"),
2912 "error should point at the lowercase TOML section: {msg}"
2913 );
2914 }
2915
2916 #[test]
2917 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
2918 let _g = env_guard();
2919 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
2920 assert_eq!(got, "inline-key");
2921 }
2922
2923 #[test]
2924 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
2925 let _g = env_guard();
2926 let pasted_secret = "sk_pasted_secret";
2929 unsafe { std::env::remove_var(pasted_secret) };
2930 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
2931 assert!(
2932 !err.to_string().contains(pasted_secret),
2933 "error must not echo configured api_key_env values"
2934 );
2935 }
2936
2937 #[test]
2938 fn is_valid_env_var_name_rules() {
2939 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
2941 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
2942 }
2943 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
2945 assert!(
2946 !is_valid_env_var_name(invalid),
2947 "{invalid} should be invalid"
2948 );
2949 }
2950 }
2951
2952 #[test]
2953 fn config_parses_with_inline_api_key_and_primary() {
2954 let f = write_toml(
2955 r#"
2956 [ui]
2957 primary = "openrouter"
2958
2959 [zai]
2960 enabled = true
2961 api_key_env = "MY_ZAI"
2962 api_key = "sk-zai-inline"
2963
2964 [openrouter]
2965 enabled = true
2966 api_key = "sk-or-inline"
2967 "#,
2968 );
2969 let c = Config::load_from(f.path()).unwrap();
2970 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
2971 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
2972 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
2973 }
2974
2975 #[test]
2976 fn openrouter_named_accounts_preserve_the_default_contract() {
2977 let f = write_toml(
2978 r#"
2979 [openrouter]
2980 enabled = true
2981 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
2982 api_key = "default-inline"
2983 show_default_account = false
2984
2985 [[openrouter.accounts]]
2986 label = "work"
2987 api_key_env = "OPENROUTER_WORK_API_KEY"
2988
2989 [[openrouter.accounts]]
2990 label = "personal"
2991 api_key = "personal-inline"
2992 "#,
2993 );
2994 let _g = env_guard();
2995 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
2996 let config = Config::load_from(f.path()).unwrap();
2997 assert!(!config.openrouter.show_default_account);
2998 assert_eq!(config.openrouter.accounts.len(), 2);
2999 assert_eq!(
3000 config.openrouter.resolve_api_key(None).unwrap(),
3001 "default-inline"
3002 );
3003 assert_eq!(
3004 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
3005 "personal-inline"
3006 );
3007 }
3008
3009 #[test]
3010 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3011 for source in [
3012 r#"
3013 [[openrouter.accounts]]
3014 label = "work"
3015 api_key = "one"
3016 [[openrouter.accounts]]
3017 label = "work"
3018 api_key = "two"
3019 "#,
3020 r#"
3021 [[openrouter.accounts]]
3022 label = "../work"
3023 api_key = "one"
3024 "#,
3025 r#"
3026 [[openrouter.accounts]]
3027 label = "work"
3028 "#,
3029 ] {
3030 let f = write_toml(source);
3031 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3032 }
3033 }
3034
3035 #[test]
3036 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3037 let mut config = OpenRouterConfig {
3038 api_key: Some("default-secret".into()),
3039 ..OpenRouterConfig::default()
3040 };
3041 config.accounts.push(OpenRouterAccount {
3042 label: "work".into(),
3043 api_key_env: None,
3044 api_key: Some("work-secret".into()),
3045 });
3046 let message = config
3047 .resolve_api_key(Some("missing"))
3048 .unwrap_err()
3049 .to_string();
3050 assert!(message.contains("missing") && message.contains("work"));
3051 assert!(!message.contains("default-secret"));
3052 assert!(!message.contains("work-secret"));
3053 }
3054
3055 #[test]
3056 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3057 let config = OpenRouterConfig {
3058 accounts: vec![OpenRouterAccount {
3059 label: "work".into(),
3060 api_key_env: Some("sk_pasted_secret".into()),
3061 api_key: None,
3062 }],
3063 ..OpenRouterConfig::default()
3064 };
3065 let _g = env_guard();
3066 unsafe { std::env::remove_var("sk_pasted_secret") };
3067 let message = config
3068 .resolve_api_key(Some("work"))
3069 .unwrap_err()
3070 .to_string();
3071 assert!(message.contains("[[openrouter.accounts]]"));
3072 assert!(!message.contains("sk_pasted_secret"));
3073 }
3074
3075 #[test]
3076 fn enabled_vendors_preserves_canonical_order() {
3077 let c = Config::default();
3080 assert_eq!(
3081 c.enabled_vendors(),
3082 vec![
3083 VendorId::Anthropic,
3084 VendorId::Openai,
3085 VendorId::Zai,
3086 VendorId::Openrouter,
3087 ]
3088 );
3089 }
3090
3091 #[test]
3092 fn deepseek_appears_when_enabled() {
3093 let f = write_toml(
3094 r#"
3095 [deepseek]
3096 enabled = true
3097 api_key = "sk-test"
3098 "#,
3099 );
3100 let c = Config::load_from(f.path()).unwrap();
3101 assert!(c.is_enabled(VendorId::Deepseek));
3102 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3103 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3104 }
3105
3106 #[test]
3107 fn tilde_paths_are_expanded_on_load() {
3108 let f = write_toml(
3112 r#"
3113 [context]
3114 projects_path = "~/.claude/projects"
3115
3116 [anthropic]
3117 credentials_path = "~/.claude/.credentials.json"
3118
3119 [[anthropic.accounts]]
3120 label = "work"
3121 credentials_path = "~/work.json"
3122 "#,
3123 );
3124 let c = Config::load_from(f.path()).unwrap();
3125 let home = crate::cache::home_dir().unwrap();
3126
3127 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3128 let got = c.anthropic.credentials_path.unwrap();
3129 assert_eq!(got, home.join(".claude/.credentials.json"));
3130 assert!(!got.to_string_lossy().contains('~'));
3131 assert_eq!(
3132 c.anthropic.accounts[0].credentials_path,
3133 home.join("work.json")
3134 );
3135 }
3136
3137 #[test]
3138 fn absolute_and_relative_paths_are_left_alone() {
3139 let f = write_toml(
3140 r#"
3141 [anthropic]
3142 credentials_path = "/etc/creds.json"
3143 "#,
3144 );
3145 let c = Config::load_from(f.path()).unwrap();
3146 assert_eq!(
3147 c.anthropic.credentials_path.unwrap(),
3148 std::path::Path::new("/etc/creds.json")
3149 );
3150
3151 let f2 = write_toml(
3153 r#"
3154 [anthropic]
3155 credentials_path = "~someone/creds.json"
3156 "#,
3157 );
3158 let c2 = Config::load_from(f2.path()).unwrap();
3159 assert_eq!(
3160 c2.anthropic.credentials_path.unwrap(),
3161 std::path::Path::new("~someone/creds.json")
3162 );
3163 }
3164
3165 #[test]
3166 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3167 let _g = path_override_guard();
3168 let p = resolved_path().expect("a config path must resolve");
3171 assert!(p.ends_with("config.toml"));
3172 let canonical = default_path().unwrap();
3173 let legacy = legacy_xdg_path().unwrap();
3174 assert!(
3175 p == canonical || p == legacy,
3176 "resolved to an unexpected location: {}",
3177 p.display()
3178 );
3179 }
3180
3181 #[test]
3182 fn misspelled_section_is_rejected_not_ignored() {
3183 let f = write_toml(
3186 r#"
3187 [openrouer]
3188 enabled = true
3189 api_key = "sk-or-v1-typo"
3190 "#,
3191 );
3192 let err = Config::load_from(f.path()).unwrap_err().to_string();
3193 assert!(
3194 err.contains("openrouer"),
3195 "error should name the typo: {err}"
3196 );
3197 }
3198
3199 #[test]
3200 fn invalid_toml_is_an_error_not_silent_defaults() {
3201 let f = write_toml("[zai\nenabled = true\n");
3202 assert!(Config::load_from(f.path()).is_err());
3203 }
3204
3205 #[test]
3206 fn a_missing_file_is_still_just_defaults() {
3207 let dir = tempfile::tempdir().unwrap();
3210 let missing = dir.path().join("nope").join("config.toml");
3211 let c = Config::load_from(&missing).unwrap();
3212 assert!(c.is_enabled(VendorId::Anthropic));
3213 }
3214
3215 #[test]
3216 fn kimi_appears_when_enabled() {
3217 let f = write_toml(
3218 r#"
3219 [kimi]
3220 enabled = true
3221 api_key = "sk-test"
3222 "#,
3223 );
3224 let c = Config::load_from(f.path()).unwrap();
3225 assert!(c.is_enabled(VendorId::Kimi));
3226 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3227 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3228 }
3229
3230 #[test]
3231 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3232 let f = write_toml(
3233 r#"
3234 [deepseek]
3235 enabled = true
3236 api_key = "sk-ds"
3237
3238 [kimi]
3239 enabled = true
3240 api_key = "sk-kimi"
3241 "#,
3242 );
3243 let c = Config::load_from(f.path()).unwrap();
3244 assert_eq!(
3245 c.enabled_vendors(),
3246 vec![
3247 VendorId::Anthropic,
3248 VendorId::Openai,
3249 VendorId::Zai,
3250 VendorId::Openrouter,
3251 VendorId::Deepseek,
3252 VendorId::Kimi,
3253 ]
3254 );
3255 }
3256
3257 #[test]
3258 fn parses_anthropic_accounts_and_looks_them_up() {
3259 let f = write_toml(
3260 r#"
3261 [anthropic]
3262 enabled = true
3263
3264 [[anthropic.accounts]]
3265 label = "personal"
3266 credentials_path = "/creds/personal.json"
3267
3268 [[anthropic.accounts]]
3269 label = "work"
3270 credentials_path = "/creds/work.json"
3271 "#,
3272 );
3273 let c = Config::load_from(f.path()).unwrap();
3274 assert_eq!(c.anthropic.accounts.len(), 2);
3275 let work = c.anthropic.account("work").unwrap();
3276 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3277 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3279 assert!(err.contains("missing") && err.contains("work"), "{err}");
3280 }
3281
3282 #[test]
3283 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3284 let f = write_toml(
3285 r#"
3286 [[anthropic.accounts]]
3287 label = "work"
3288 credentials_path = "/creds/work-one.json"
3289
3290 [[anthropic.accounts]]
3291 label = "work"
3292 credentials_path = "/creds/work-two.json"
3293 "#,
3294 );
3295 let err = Config::load_from(f.path()).unwrap_err().to_string();
3296 assert!(
3297 err.contains("duplicate anthropic account label \"work\""),
3298 "{err}"
3299 );
3300 }
3301
3302 #[test]
3303 fn account_label_rejects_path_like_names() {
3304 let cfg = AnthropicConfig::default();
3305 for bad in [
3306 "",
3307 ".",
3308 "..",
3309 "a/b",
3310 r"a\b",
3311 "C:work",
3312 "line\nbreak",
3313 "tab\tname",
3314 "usage.json",
3315 ".stale",
3316 ".last_error",
3317 ".fetch.lock",
3318 ] {
3319 let err = cfg.account(bad).unwrap_err();
3320 assert!(
3321 format!("{err:?}").contains("invalid anthropic account label"),
3322 "{bad:?} should be rejected as a label"
3323 );
3324 }
3325 }
3326
3327 #[test]
3328 fn anthropic_accounts_default_to_empty() {
3329 assert!(Config::default().anthropic.accounts.is_empty());
3332 assert!(Config::default().anthropic.accounts_dir.is_none());
3333 }
3334
3335 fn seed_account_dir(root: &std::path::Path, label: &str) {
3341 let dir = root.join(label);
3342 std::fs::create_dir_all(&dir).unwrap();
3343 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3344 }
3345
3346 #[test]
3347 fn discovers_account_dirs_in_claude_config_dir_layout() {
3348 let td = tempfile::tempdir().unwrap();
3349 seed_account_dir(td.path(), "work");
3350 seed_account_dir(td.path(), "personal");
3351 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3354 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3356
3357 let cfg = AnthropicConfig {
3358 accounts_dir: Some(td.path().to_path_buf()),
3359 ..Default::default()
3360 };
3361 let all = cfg.all_accounts();
3362 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3363 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3364 assert_eq!(
3365 all[2].credentials_path,
3366 td.path().join("work").join(".credentials.json")
3367 );
3368 }
3369
3370 #[test]
3371 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3372 let td = tempfile::tempdir().unwrap();
3373 seed_account_dir(td.path(), "work");
3374 let cfg = AnthropicConfig {
3375 accounts: vec![AnthropicAccount {
3376 label: "work".into(),
3377 credentials_path: "/explicit/work.json".into(),
3378 }],
3379 accounts_dir: Some(td.path().to_path_buf()),
3380 ..Default::default()
3381 };
3382 let all = cfg.all_accounts();
3383 assert_eq!(all.len(), 1, "no duplicate label");
3384 assert_eq!(
3385 all[0].credentials_path,
3386 std::path::Path::new("/explicit/work.json"),
3387 "explicit entry wins"
3388 );
3389 seed_account_dir(td.path(), "other");
3391 assert_eq!(cfg.account("other").unwrap().label, "other");
3392 }
3393
3394 #[test]
3395 fn missing_accounts_dir_is_silently_empty_not_an_error() {
3396 let cfg = AnthropicConfig {
3397 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3398 ..Default::default()
3399 };
3400 assert!(cfg.all_accounts().is_empty());
3401 }
3402
3403 #[test]
3404 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3405 let f = write_toml(
3406 r#"
3407 [[openai.accounts]]
3408 label = "work"
3409 codex_auth_path = "~/.codex-work/auth.json"
3410 "#,
3411 );
3412 let c = Config::load_from(f.path()).unwrap();
3413 let home = crate::cache::home_dir().unwrap();
3414 assert_eq!(
3415 c.openai.accounts[0].codex_auth_path,
3416 home.join(".codex-work/auth.json")
3417 );
3418 }
3419
3420 #[test]
3421 fn accounts_dir_is_tilde_expanded_on_load() {
3422 let f = write_toml(
3423 r#"
3424 [anthropic]
3425 accounts_dir = "~/.config/ai-usagebar/accounts"
3426 "#,
3427 );
3428 let c = Config::load_from(f.path()).unwrap();
3429 let home = crate::cache::home_dir().unwrap();
3430 assert_eq!(
3431 c.anthropic.accounts_dir,
3432 Some(home.join(".config/ai-usagebar/accounts"))
3433 );
3434 }
3435
3436 #[test]
3437 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3438 let f = write_toml(
3439 r#"
3440 [anthropic]
3441 desktop_profiles_dir = "~/.claude-acc/profiles"
3442 "#,
3443 );
3444 let c = Config::load_from(f.path()).unwrap();
3445 let home = crate::cache::home_dir().unwrap();
3446 assert_eq!(
3447 c.anthropic.desktop_profiles_dir,
3448 Some(home.join(".claude-acc/profiles"))
3449 );
3450 }
3451
3452 #[test]
3453 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3454 let cfg = AnthropicConfig {
3455 accounts: vec![
3456 AnthropicAccount {
3457 label: "work".into(),
3458 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3459 },
3460 AnthropicAccount {
3461 label: "personal".into(),
3462 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3463 },
3464 ],
3465 ..Default::default()
3466 };
3467
3468 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
3469 assert!(
3470 matches!(&idle, CredsTarget::Named { config_dir, .. }
3471 if config_dir == std::path::Path::new("/tmp/accounts/work")),
3472 "{idle:?}"
3473 );
3474
3475 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
3477 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
3478
3479 assert_eq!(idle_cache.dir(), live_cache.dir());
3482 }
3483
3484 #[test]
3485 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
3486 let cfg = AnthropicConfig {
3487 accounts: vec![AnthropicAccount {
3488 label: "work".into(),
3489 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3490 }],
3491 ..Default::default()
3492 };
3493 let (target, _) = cfg.account_target_with("work", None).unwrap();
3494 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
3495 }
3496
3497 fn config_example() -> PathBuf {
3501 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
3502 }
3503
3504 #[test]
3505 fn shipped_example_parses_as_a_real_config() {
3506 let c = Config::load_from(&config_example()).unwrap();
3511 assert!(!c.context.enabled);
3512 assert!(c.is_enabled(VendorId::Anthropic));
3513 assert!(c.is_enabled(VendorId::Openai));
3514 assert!(!c.is_enabled(VendorId::AnthropicApi));
3515 assert!(!c.is_enabled(VendorId::Deepseek));
3516 assert!(!c.is_enabled(VendorId::Kimi));
3517 assert!(!c.is_enabled(VendorId::Kilo));
3518 assert!(!c.is_enabled(VendorId::Novita));
3519 assert!(!c.is_enabled(VendorId::Moonshot));
3520 assert!(!c.is_enabled(VendorId::Grok));
3521 assert!(!c.is_enabled(VendorId::Cursor));
3522 assert!(!c.is_enabled(VendorId::Minimax));
3523 }
3524
3525 #[test]
3526 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
3527 let text = std::fs::read_to_string(config_example()).unwrap();
3532 let live: Vec<&str> = text
3533 .lines()
3534 .map(str::trim)
3535 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
3536 .collect();
3537 assert!(
3538 live.is_empty(),
3539 "admin_key_env must stay commented out while it is inert: {live:?}"
3540 );
3541 assert!(
3544 text.contains("admin_key_env") && text.contains("RESERVED"),
3545 "the example should keep describing admin_key_env as reserved"
3546 );
3547 }
3548
3549 #[test]
3550 fn admin_key_env_is_accepted_but_changes_nothing() {
3551 let f = write_toml(
3555 r#"
3556 [openai]
3557 admin_key_env = "SOME_ADMIN_KEY"
3558 "#,
3559 );
3560 let c = Config::load_from(f.path()).unwrap();
3561 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
3562 let default = OpenAiConfig::default();
3564 assert_eq!(c.openai.enabled, default.enabled);
3565 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
3566 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
3567 }
3568
3569 #[test]
3570 fn config_example_documents_every_vendor_without_secrets() {
3571 let raw = std::fs::read_to_string(config_example()).unwrap();
3572 let cfg = Config::load_from(&config_example()).unwrap();
3573 for id in VendorId::all() {
3576 let section = id.slug();
3577 assert!(
3578 raw.contains(&format!("[{section}]")),
3579 "config.example.toml has no [{section}] section"
3580 );
3581 }
3582
3583 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
3586 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
3587 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
3588 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
3589 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
3590 assert!(!cfg.supergrok.enabled);
3591 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
3592 assert_eq!(
3593 cfg.supergrok
3594 .grok_binary
3595 .file_name()
3596 .and_then(|p| p.to_str()),
3597 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
3598 );
3599 assert!(cfg.supergrok.auth_path.is_none());
3600 assert!(cfg.supergrok.config_path.is_none());
3601 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
3602 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
3603 }
3604
3605 #[test]
3606 fn supergrok_binary_must_not_be_empty() {
3607 let file = write_toml(
3608 r#"
3609 [supergrok]
3610 enabled = true
3611 grok_binary = ""
3612 "#,
3613 );
3614 let error = Config::load_from(file.path()).unwrap_err().to_string();
3615 assert!(error.contains("grok_binary must not be empty"));
3616 }
3617
3618 #[test]
3619 fn supergrok_paths_are_tilde_expanded() {
3620 let file = write_toml(
3621 r#"
3622 [supergrok]
3623 grok_binary = "~/bin/grok"
3624 auth_path = "~/.grok/auth.json"
3625 config_path = "~/.grok/config.toml"
3626 "#,
3627 );
3628 let config = Config::load_from(file.path()).unwrap();
3629 let home = crate::cache::home_dir().unwrap();
3630 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
3631 assert_eq!(
3632 config.supergrok.auth_path,
3633 Some(home.join(".grok/auth.json"))
3634 );
3635 assert_eq!(
3636 config.supergrok.config_path,
3637 Some(home.join(".grok/config.toml"))
3638 );
3639 }
3640
3641 #[test]
3642 fn kiro_db_path_is_tilde_expanded() {
3643 let f = write_toml(
3644 r#"
3645 [kiro]
3646 db_path = "~/kiro-data.sqlite3"
3647 "#,
3648 );
3649 let c = Config::load_from(f.path()).unwrap();
3650 let home = crate::cache::home_dir().unwrap();
3651 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
3652 }
3653
3654 #[test]
3655 fn kiro_appears_when_enabled() {
3656 let f = write_toml(
3657 r#"
3658 [kiro]
3659 enabled = true
3660 "#,
3661 );
3662 let c = Config::load_from(f.path()).unwrap();
3663 assert!(c.is_enabled(VendorId::Kiro));
3664 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
3665 }
3666
3667 #[test]
3668 fn cursor_db_path_is_tilde_expanded() {
3669 let f = write_toml(
3670 r#"
3671 [cursor]
3672 db_path = "~/cursor-state.vscdb"
3673 "#,
3674 );
3675 let c = Config::load_from(f.path()).unwrap();
3676 let home = crate::cache::home_dir().unwrap();
3677 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
3678 }
3679
3680 #[test]
3681 fn cursor_agent_auth_path_is_tilde_expanded() {
3682 let f = write_toml(
3683 r#"
3684 [cursor]
3685 agent_auth_path = "~/cursor-agent-auth.json"
3686 "#,
3687 );
3688 let c = Config::load_from(f.path()).unwrap();
3689 let home = crate::cache::home_dir().unwrap();
3690 assert_eq!(
3691 c.cursor.agent_auth_path,
3692 Some(home.join("cursor-agent-auth.json"))
3693 );
3694 }
3695
3696 #[test]
3697 fn cursor_appears_when_enabled() {
3698 let f = write_toml(
3699 r#"
3700 [cursor]
3701 enabled = true
3702 "#,
3703 );
3704 let c = Config::load_from(f.path()).unwrap();
3705 assert!(c.is_enabled(VendorId::Cursor));
3706 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
3707 }
3708
3709 #[test]
3710 fn add_account_appends_and_preserves_existing() {
3711 let mut doc: toml_edit::DocumentMut = r#"
3712# keep me
3713[anthropic]
3714enabled = true
3715
3716[[anthropic.accounts]]
3717label = "personal"
3718credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
3719"#
3720 .parse()
3721 .unwrap();
3722 add_anthropic_account_to_doc(
3723 &mut doc,
3724 "work",
3725 "~/.config/ai-usagebar/accounts/work/.credentials.json",
3726 )
3727 .unwrap();
3728 let rendered = doc.to_string();
3729 assert!(rendered.contains("# keep me"), "comment must survive");
3730 let f = write_toml(&rendered);
3732 let c = Config::load_from(f.path()).unwrap();
3733 let labels: Vec<&str> = c
3734 .anthropic
3735 .accounts
3736 .iter()
3737 .map(|a| a.label.as_str())
3738 .collect();
3739 assert_eq!(labels, vec!["personal", "work"]);
3740 }
3741
3742 #[test]
3743 fn add_account_to_empty_doc_is_loadable() {
3744 let mut doc = toml_edit::DocumentMut::new();
3745 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
3746 let f = write_toml(&doc.to_string());
3747 let c = Config::load_from(f.path()).unwrap();
3748 assert_eq!(c.anthropic.accounts.len(), 1);
3749 assert_eq!(c.anthropic.accounts[0].label, "solo");
3750 }
3751
3752 #[test]
3753 fn add_account_rejects_duplicate_label() {
3754 let mut doc: toml_edit::DocumentMut = r#"
3755[[anthropic.accounts]]
3756label = "work"
3757credentials_path = "~/w/.credentials.json"
3758"#
3759 .parse()
3760 .unwrap();
3761 assert!(
3762 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
3763 "a duplicate label must be rejected, not appended"
3764 );
3765 }
3766
3767 #[test]
3768 fn add_account_rejects_bad_label() {
3769 let mut doc = toml_edit::DocumentMut::new();
3770 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
3771 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
3772 }
3773
3774 #[test]
3775 fn tildify_collapses_home_only() {
3776 let home = Path::new("/Users/me");
3777 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
3778 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
3779 }
3780
3781 #[test]
3782 fn default_account_credentials_path_nests_under_config_dir() {
3783 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
3784 assert_eq!(
3785 default_account_credentials_path(cfg, "work"),
3786 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
3787 );
3788 }
3789 const CUSTOM_BLOCK: &str = r#"
3792[[custom]]
3793id = "mytool"
3794name = "My Tool"
3795short_name = "myt"
3796enabled = true
3797url = "https://api.example.test/v1/usage"
3798api_key_env = "MYTOOL_API_KEY"
3799auth_header = "Authorization"
3800auth_scheme = "Bearer"
3801plan = "Pro"
3802cache_ttl_secs = 120
3803[custom.headers]
3804X-Org = "org_1"
3805[[custom.metrics]]
3806label = "Requests"
3807used = "/requests/used"
3808limit = "/requests/limit"
3809resets_at = "/requests/reset"
3810window_secs = 3600
3811[[custom.texts]]
3812label = "Tier"
3813value = "/tier"
3814"#;
3815
3816 fn custom_with(from: &str, to: &str) -> String {
3817 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
3818 CUSTOM_BLOCK.replace(from, to)
3819 }
3820
3821 fn custom_error(toml: &str) -> String {
3822 Config::load_from(write_toml(toml).path())
3823 .unwrap_err()
3824 .to_string()
3825 }
3826
3827 fn assert_custom_rejected(toml: &str, needle: &str) {
3828 let msg = custom_error(toml);
3829 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
3830 assert!(
3831 msg.contains("[[custom]]"),
3832 "the error must locate the section: {msg}"
3833 );
3834 }
3835
3836 #[test]
3837 fn custom_block_parses_every_field() {
3838 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
3839 assert_eq!(config.custom.len(), 1);
3840 let c = &config.custom[0];
3841 assert_eq!(c.id, "mytool");
3842 assert_eq!(c.name, "My Tool");
3843 assert_eq!(c.short_name, "myt");
3844 assert_eq!(
3845 c.brand, None,
3846 "a custom provider has no mark unless it asks"
3847 );
3848 assert!(c.enabled);
3849 assert_eq!(c.url, "https://api.example.test/v1/usage");
3850 assert!(!c.allow_http);
3851 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
3852 assert_eq!(c.api_key, None);
3853 assert_eq!(c.auth_header, "Authorization");
3854 assert_eq!(c.auth_scheme, "Bearer");
3855 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
3856 assert_eq!(c.plan.as_deref(), Some("Pro"));
3857 assert_eq!(c.plan_path, None);
3858 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
3859 assert_eq!(c.metrics.len(), 1);
3860 assert_eq!(c.metrics[0].label, "Requests");
3861 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
3862 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
3863 assert_eq!(c.metrics[0].percent, None);
3864 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
3865 assert_eq!(c.metrics[0].window_secs, Some(3600));
3866 assert_eq!(c.texts.len(), 1);
3867 assert_eq!(c.texts[0].label, "Tier");
3868 assert_eq!(c.texts[0].value, "/tier");
3869 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
3870 }
3871
3872 #[test]
3873 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
3874 let config: Config = toml::from_str(
3875 r#"
3876 [[custom]]
3877 id = "bare"
3878 short_name = "bre"
3879 url = "https://example.test/u"
3880 [[custom.metrics]]
3881 label = "Q"
3882 percent = "/pct"
3883 "#,
3884 )
3885 .unwrap();
3886 let c = &config.custom[0];
3887 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
3888 assert!(!c.enabled);
3889 assert!(!c.allow_http);
3890 assert_eq!(c.api_key_env, "");
3891 assert_eq!(c.auth_header, "Authorization");
3892 assert_eq!(c.auth_scheme, "Bearer");
3893 assert_eq!(c.cache_ttl_secs, 60);
3894 assert!(config.validate().is_ok());
3895 assert!(Config::default().custom.is_empty());
3896 }
3897
3898 #[test]
3899 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
3900 let config = Config::load_from(
3901 write_toml(&custom_with(
3902 r#"short_name = "myt""#,
3903 "short_name = \"myt\"\nbrand = \"opencode-go\"",
3904 ))
3905 .path(),
3906 )
3907 .unwrap();
3908 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
3909
3910 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
3914 assert_custom_rejected(
3915 &custom_with(
3916 r#"short_name = "myt""#,
3917 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
3918 ),
3919 "must name a built-in vendor",
3920 );
3921 }
3922 }
3923
3924 #[test]
3925 fn custom_rejects_a_malformed_id() {
3926 let long = "a".repeat(33);
3927 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
3928 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
3929 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
3930 assert!(msg.contains("must match"), "{id:?}: {msg}");
3931 }
3932 }
3933
3934 #[test]
3935 fn custom_rejects_a_builtin_slug_as_id() {
3936 assert_custom_rejected(
3937 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
3938 "is a built-in vendor",
3939 );
3940 assert_custom_rejected(
3941 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
3942 "is a built-in vendor",
3943 );
3944 }
3945
3946 #[test]
3947 fn custom_rejects_duplicate_ids() {
3948 let twice = format!(
3949 "{}{}",
3950 CUSTOM_BLOCK,
3951 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
3952 );
3953 assert_custom_rejected(&twice, "duplicate id");
3954 }
3955
3956 #[test]
3957 fn custom_rejects_a_name_over_48_chars() {
3958 let long = "n".repeat(49);
3959 assert_custom_rejected(
3960 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
3961 "name must be 1 to 48 characters",
3962 );
3963 }
3964
3965 #[test]
3966 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
3967 for short in ["my", "myto", "MYT", "m1t"] {
3968 assert_custom_rejected(
3969 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
3970 "exactly 3 lowercase ASCII letters",
3971 );
3972 }
3973 }
3974
3975 #[test]
3976 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
3977 assert_custom_rejected(
3978 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
3979 "already used by a built-in vendor",
3980 );
3981 let twice = format!(
3982 "{}{}",
3983 CUSTOM_BLOCK,
3984 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
3985 );
3986 assert_custom_rejected(&twice, "already used by a built-in vendor");
3987 }
3988
3989 #[test]
3990 fn custom_rejects_http_unless_allowed() {
3991 let plain = custom_with(
3992 r#"url = "https://api.example.test/v1/usage""#,
3993 r#"url = "http://localhost:8080/usage""#,
3994 );
3995 assert_custom_rejected(&plain, "url must use https://");
3996 let allowed = plain.replace(
3997 r#"url = "http://localhost:8080/usage""#,
3998 "url = \"http://localhost:8080/usage\"\nallow_http = true",
3999 );
4000 assert!(
4001 Config::load_from(write_toml(&allowed).path()).is_ok(),
4002 "allow_http must permit http://"
4003 );
4004 }
4005
4006 #[test]
4007 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4008 assert_custom_rejected(
4009 &custom_with(
4010 r#"url = "https://api.example.test/v1/usage""#,
4011 r#"url = "https://user:pw@api.example.test/v1/usage""#,
4012 ),
4013 "must not carry credentials",
4014 );
4015 assert_custom_rejected(
4016 &custom_with(
4017 r#"url = "https://api.example.test/v1/usage""#,
4018 r#"url = "not a url""#,
4019 ),
4020 "is not a valid URL",
4021 );
4022 assert_custom_rejected(
4023 &custom_with(
4024 r#"url = "https://api.example.test/v1/usage""#,
4025 r#"url = "ftp://api.example.test/v1/usage""#,
4026 ),
4027 "is not http or https",
4028 );
4029 }
4030
4031 #[test]
4032 fn custom_rejects_an_invalid_api_key_env() {
4033 assert_custom_rejected(
4034 &custom_with(
4035 r#"api_key_env = "MYTOOL_API_KEY""#,
4036 r#"api_key_env = "1BAD-NAME""#,
4037 ),
4038 "is not a valid environment variable name",
4039 );
4040 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4041 assert!(
4042 Config::load_from(write_toml(&none).path()).is_ok(),
4043 "an empty api_key_env means inline-only and is valid"
4044 );
4045 }
4046
4047 #[test]
4048 fn custom_rejects_an_invalid_auth_header_name() {
4049 assert_custom_rejected(
4050 &custom_with(
4051 r#"auth_header = "Authorization""#,
4052 r#"auth_header = "X Api Key""#,
4053 ),
4054 "auth_header \"X Api Key\" is not a valid HTTP header name",
4055 );
4056 }
4057
4058 #[test]
4059 fn custom_rejects_a_control_char_in_auth_scheme() {
4060 assert_custom_rejected(
4061 &custom_with(
4062 r#"auth_scheme = "Bearer""#,
4063 "auth_scheme = \"Bearer\\u0007\"",
4064 ),
4065 "auth_scheme contains characters that are not valid",
4066 );
4067 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4068 assert!(
4069 Config::load_from(write_toml(&bare).path()).is_ok(),
4070 "an empty scheme (bare key) is valid"
4071 );
4072 }
4073
4074 #[test]
4075 fn custom_rejects_a_bad_extra_header() {
4076 assert_custom_rejected(
4077 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4078 "headers must not repeat auth_header",
4079 );
4080 assert_custom_rejected(
4081 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4082 "is not a valid HTTP header name",
4083 );
4084 assert_custom_rejected(
4085 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4086 "has a value that is not valid in an HTTP header",
4087 );
4088 }
4089
4090 #[test]
4091 fn custom_rejects_cache_ttl_outside_10_to_3600() {
4092 for ttl in ["9", "3601"] {
4093 assert_custom_rejected(
4094 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4095 "cache_ttl_secs must be between 10 and 3600",
4096 );
4097 }
4098 }
4099
4100 #[test]
4101 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4102 let toml = r#"
4103[[custom]]
4104id = "empty"
4105short_name = "emp"
4106url = "https://example.test/u"
4107"#;
4108 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4109 }
4110
4111 #[test]
4112 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4113 assert_custom_rejected(
4114 &custom_with(
4115 r#"limit = "/requests/limit""#,
4116 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4117 ),
4118 "must set `percent`, or both `used` and `limit`",
4119 );
4120 assert_custom_rejected(
4121 &custom_with("limit = \"/requests/limit\"\n", ""),
4122 "must set `percent`, or both `used` and `limit`",
4123 );
4124 }
4125
4126 #[test]
4127 fn custom_rejects_a_pointer_without_a_leading_slash() {
4128 assert_custom_rejected(
4129 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4130 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4131 );
4132 assert_custom_rejected(
4133 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4134 "value \"tier\" must be an RFC 6901 JSON Pointer",
4135 );
4136 assert_custom_rejected(
4137 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4138 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4139 );
4140 assert_custom_rejected(
4141 &custom_with(
4142 r#"resets_at = "/requests/reset""#,
4143 "resets_at = \"/re\\u001bset\"",
4144 ),
4145 "resets_at",
4146 );
4147 }
4148
4149 #[test]
4150 fn custom_rejects_a_label_outside_1_to_64_chars() {
4151 let long = "l".repeat(65);
4152 assert_custom_rejected(
4153 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4154 "metric label",
4155 );
4156 assert_custom_rejected(
4157 &custom_with(r#"label = "Tier""#, r#"label = """#),
4158 "text label \"\" must be 1 to 64 characters",
4159 );
4160 }
4161
4162 #[test]
4163 fn custom_rejects_window_secs_under_60() {
4164 assert_custom_rejected(
4165 &custom_with("window_secs = 3600", "window_secs = 59"),
4166 "window_secs must be at least 60",
4167 );
4168 }
4169
4170 #[test]
4171 fn custom_rejects_duplicate_metric_and_text_labels() {
4172 let metric_twice = custom_with(
4173 "window_secs = 3600\n",
4174 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4175 );
4176 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4177 let text_twice =
4178 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4179 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4180 }
4181
4182 #[test]
4183 fn enabled_custom_and_custom_by_id_select_entries() {
4184 let two = format!(
4185 "{}{}",
4186 CUSTOM_BLOCK,
4187 custom_with(r#"id = "mytool""#, r#"id = "off""#)
4188 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4189 .replace("enabled = true", "enabled = false")
4190 );
4191 let config = Config::load_from(write_toml(&two).path()).unwrap();
4192 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4193 assert_eq!(enabled, ["mytool"]);
4194 assert_eq!(
4195 config.custom_by_id("off").map(|c| c.name.as_str()),
4196 Some("My Tool")
4197 );
4198 assert!(config.custom_by_id("nope").is_none());
4199 }
4200
4201 #[cfg(unix)]
4202 #[test]
4203 fn has_inline_secrets_sees_a_custom_inline_key() {
4204 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4205 assert!(!without.has_inline_secrets());
4206 let with: Config = toml::from_str(&custom_with(
4207 r#"api_key_env = "MYTOOL_API_KEY""#,
4208 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4209 ))
4210 .unwrap();
4211 assert!(with.has_inline_secrets());
4212 }
4213
4214 #[test]
4215 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4216 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4217 let mut spec = CustomProviderConfig {
4218 id: "mytool".into(),
4219 api_key_env: var.into(),
4220 api_key: Some("sk-inline-secret".into()),
4221 ..CustomProviderConfig::default()
4222 };
4223 unsafe { std::env::set_var(var, "sk-env-secret") };
4224 let from_env = spec.resolve_api_key();
4225 unsafe { std::env::remove_var(var) };
4226 assert_eq!(from_env.unwrap(), "sk-env-secret");
4227
4228 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4229
4230 spec.api_key = Some(String::new());
4231 let err = spec.resolve_api_key().unwrap_err();
4232 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4233 let msg = err.to_string();
4234 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4235 assert!(msg.contains(var), "{msg}");
4236 assert!(!msg.contains("secret"), "{msg}");
4237
4238 spec.api_key_env = String::new();
4239 let msg = spec.resolve_api_key().unwrap_err().to_string();
4240 assert!(msg.contains("set `api_key`"), "{msg}");
4241 }
4242
4243 #[test]
4244 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4245 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4246 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4247 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4248 Config::load_from(file.path()).unwrap();
4249 assert!(
4250 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4251 "a custom provider's env var must be scrubbed from subprocesses"
4252 );
4253 }
4254
4255 #[test]
4260 fn every_config_section_parses_to_its_vendors_enabled_switch() {
4261 for vendor in VendorId::all() {
4262 let text = format!(
4263 "[{}]
4264enabled = true
4265",
4266 vendor.config_section()
4267 );
4268 let config: Config = toml::from_str(&text)
4269 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4270 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4271 let others = VendorId::all()
4272 .iter()
4273 .filter(|other| *other != vendor && config.is_enabled(**other))
4274 .count();
4275 assert_eq!(
4276 others,
4277 Config::default().enabled_vendors().len()
4278 - usize::from(Config::default().is_enabled(*vendor)),
4279 "[{}] enabled a different vendor",
4280 vendor.config_section()
4281 );
4282 }
4283 }
4284
4285 #[test]
4286 fn tray_section_parses_and_defaults_to_notify() {
4287 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4288 let config = Config::load_from(file.path()).unwrap();
4289 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4290 assert_eq!(config.tray.updates(), UpdateMode::Auto);
4291
4292 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4293 assert_eq!(empty.tray, TrayConfig::default());
4294 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4295 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4296 assert_eq!(UpdateMode::parse("weekly"), None);
4297 assert_eq!(UpdateMode::Auto.as_str(), "auto");
4298 }
4299
4300 #[test]
4301 fn tray_section_rejects_a_misspelled_mode() {
4302 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4303 assert!(Config::load_from(file.path()).is_err());
4304 }
4305
4306 #[test]
4307 fn tray_refresh_minutes_defaults_to_five_and_parses() {
4308 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4309 assert_eq!(empty.tray.refresh_minutes, None);
4310 assert_eq!(empty.tray.refresh_minutes(), 5);
4311
4312 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4313 let config = Config::load_from(file.path()).unwrap();
4314 assert_eq!(config.tray.refresh_minutes(), 10);
4315 }
4316
4317 #[test]
4318 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4319 for minutes in ["3", "0"] {
4320 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4321 let error = Config::load_from(file.path()).unwrap_err().to_string();
4322 assert!(error.contains("[tray] refresh_minutes"), "{error}");
4323 assert!(error.contains("1, 5 or 10"), "{error}");
4324 }
4325 }
4326
4327 #[test]
4328 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4329 let dir = tempfile::tempdir().unwrap();
4330 let path = dir.path().join("config.toml");
4331 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4332
4333 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4334 let text = std::fs::read_to_string(&path).unwrap();
4335 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4336 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4337
4338 set_tray_value(&path, "refresh_minutes", None).unwrap();
4339 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4340 }
4341
4342 #[test]
4343 fn set_tray_value_creates_replaces_and_removes_keys() {
4344 let dir = tempfile::tempdir().unwrap();
4345 let path = dir.path().join("config.toml");
4346 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4347
4348 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4349 let text = std::fs::read_to_string(&path).unwrap();
4350 assert!(text.contains("# primary = \"anthropic\""), "{text}");
4351 assert!(
4352 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4353 "{text}"
4354 );
4355
4356 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
4357 set_tray_value(&path, "updates", Some("off".into())).unwrap();
4358 let config = Config::load_from(&path).unwrap();
4359 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
4360 assert_eq!(config.tray.updates(), UpdateMode::Off);
4361
4362 set_tray_value(&path, "shortcut", None).unwrap();
4363 let text = std::fs::read_to_string(&path).unwrap();
4364 assert!(!text.contains("shortcut"), "{text}");
4365 assert!(text.contains("updates = \"off\""), "{text}");
4366
4367 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
4369 set_tray_value(&path, "shortcut", None).unwrap();
4370 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
4371 }
4372
4373 #[test]
4374 fn set_value_keeps_the_trailing_comment_when_replacing() {
4375 let mut doc: toml_edit::DocumentMut =
4376 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
4377 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
4378 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
4379 }
4380
4381 #[test]
4382 fn enable_vendors_in_creates_a_missing_config() {
4383 let dir = tempfile::TempDir::new().unwrap();
4384 let path = dir.path().join("sub").join("config.toml");
4385
4386 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4387
4388 assert_eq!(
4389 std::fs::read_to_string(&path).unwrap(),
4390 "[grok]
4391enabled = true
4392"
4393 );
4394 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4395 }
4396
4397 #[test]
4398 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
4399 let dir = tempfile::TempDir::new().unwrap();
4400 let path = dir.path().join("config.toml");
4401 let original = "# my settings
4402[zai]
4403api_key = \"x\" # keep
4404enabled = false
4405";
4406 std::fs::write(&path, original).unwrap();
4407
4408 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
4409
4410 let text = std::fs::read_to_string(&path).unwrap();
4411 assert!(
4412 text.starts_with(
4413 "# my settings
4414"
4415 ),
4416 "{text}"
4417 );
4418 assert!(
4419 text.contains(
4420 "api_key = \"x\" # keep
4421"
4422 ),
4423 "{text}"
4424 );
4425 assert!(
4426 text.contains(
4427 "[grok]
4428enabled = true
4429"
4430 ),
4431 "{text}"
4432 );
4433 assert!(
4434 text.contains(
4435 "[opencode-go]
4436enabled = true
4437"
4438 ),
4439 "{text}"
4440 );
4441 let config = Config::load_from(&path).unwrap();
4442 assert!(
4443 !config.is_enabled(VendorId::Zai),
4444 "never widens to false, never flips others"
4445 );
4446 assert!(config.is_enabled(VendorId::Grok));
4447 assert!(config.is_enabled(VendorId::OpenCodeGo));
4448 }
4449
4450 #[test]
4451 fn enable_vendors_in_leaves_an_explicit_false_alone() {
4452 let dir = tempfile::TempDir::new().unwrap();
4453 let path = dir.path().join("config.toml");
4454 let original = "[grok]
4455enabled = false # off
4456api_key = \"k\"
4457";
4458 std::fs::write(&path, original).unwrap();
4459
4460 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4464
4465 assert!(written.is_empty(), "{written:?}");
4466 assert_eq!(
4467 std::fs::read_to_string(&path).unwrap(),
4468 original,
4469 "the file must not be rewritten at all"
4470 );
4471 }
4472
4473 #[test]
4474 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
4475 let dir = tempfile::TempDir::new().unwrap();
4476 let path = dir.path().join("config.toml");
4477 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
4478
4479 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4480
4481 assert_eq!(written, vec![VendorId::Grok]);
4482 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4483 }
4484
4485 #[test]
4486 fn enable_vendors_in_is_textually_idempotent() {
4487 let dir = tempfile::TempDir::new().unwrap();
4488 let path = dir.path().join("config.toml");
4489 let original = "[grok]
4490enabled = true
4491
4492# trailing
4493";
4494 std::fs::write(&path, original).unwrap();
4495 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
4496
4497 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4498
4499 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
4500 assert_eq!(
4501 std::fs::metadata(&path).unwrap().modified().unwrap(),
4502 before,
4503 "an unchanged document must not be rewritten"
4504 );
4505 }
4506
4507 #[test]
4508 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
4509 let dir = tempfile::TempDir::new().unwrap();
4510 let path = dir.path().join("config.toml");
4511
4512 enable_vendors_in(&path, &[]).unwrap();
4513
4514 assert!(!path.exists());
4515 }
4516}