1use std::collections::{BTreeMap, HashSet};
22use std::path::{Path, PathBuf};
23
24#[cfg(unix)]
25use std::os::unix::fs::{MetadataExt, PermissionsExt};
26
27use serde::{Deserialize, Serialize};
28
29use crate::anthropic::creds::CredsTarget;
30use crate::balance::{DisplayPrefs, Headline};
31use crate::cache::Cache;
32use crate::error::{AppError, Result};
33use crate::vendor::VendorId;
34
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct Config {
44 pub ui: UiConfig,
45 pub tray: TrayConfig,
46 pub context: ContextConfig,
47 pub anthropic: AnthropicConfig,
48 pub anthropic_api: AnthropicApiConfig,
49 pub openai: OpenAiConfig,
50 pub copilot: CopilotConfig,
51 pub zai: ZaiConfig,
52 pub openrouter: OpenRouterConfig,
53 pub deepseek: DeepseekConfig,
54 pub kimi: KimiConfig,
55 pub kilo: KiloConfig,
56 pub novita: NovitaConfig,
57 pub moonshot: MoonshotConfig,
58 pub grok: GrokConfig,
59 pub supergrok: SuperGrokConfig,
60 pub grokbot: GrokbotConfig,
61 pub antigravity: AntigravityConfig,
62 pub cursor: CursorConfig,
63 pub minimax: MinimaxConfig,
64 pub kiro: KiroConfig,
65 pub nous: NousConfig,
66 #[serde(rename = "opencode-go")]
67 pub opencode_go: OpenCodeGoConfig,
68 pub commandcode: CommandCodeConfig,
69 pub ollama: OllamaConfig,
70 pub orcarouter: OrcaRouterConfig,
71 pub modelstudio: ModelStudioConfig,
72 pub custom: Vec<CustomProviderConfig>,
74}
75
76#[derive(Debug, Clone, Default, Deserialize, Serialize)]
80#[serde(default)]
81pub struct UiConfig {
82 pub primary: Option<VendorId>,
84 pub overview_vendors: Option<Vec<VendorId>>,
88 pub vendor_box: Option<VendorBoxStyle>,
90}
91
92impl UiConfig {
93 pub fn vendor_box(&self) -> VendorBoxStyle {
94 self.vendor_box.unwrap_or_default()
95 }
96}
97
98#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
103#[serde(default)]
104pub struct TrayConfig {
105 pub shortcut: Option<String>,
108 pub refresh_minutes: Option<u64>,
111 pub updates: Option<UpdateMode>,
113}
114
115pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
118const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
119
120impl TrayConfig {
121 pub fn refresh_minutes(&self) -> u64 {
122 self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
123 }
124
125 pub fn updates(&self) -> UpdateMode {
126 self.updates.unwrap_or_default()
127 }
128}
129
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum UpdateMode {
135 Auto,
136 #[default]
137 Notify,
138 Off,
139}
140
141impl UpdateMode {
142 pub fn as_str(self) -> &'static str {
143 match self {
144 Self::Auto => "auto",
145 Self::Notify => "notify",
146 Self::Off => "off",
147 }
148 }
149
150 pub fn parse(text: &str) -> Option<Self> {
151 match text.trim().to_ascii_lowercase().as_str() {
152 "auto" => Some(Self::Auto),
153 "notify" => Some(Self::Notify),
154 "off" => Some(Self::Off),
155 _ => None,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
162#[serde(rename_all = "lowercase")]
163pub enum VendorBoxStyle {
164 #[default]
166 Sidebar,
167 Navbar,
169 None,
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
176#[serde(rename_all = "lowercase")]
177pub enum ContextLayout {
178 #[default]
180 Full,
181 Split,
183 Bottom,
185}
186
187impl ContextLayout {
188 pub fn next(self) -> Self {
189 match self {
190 ContextLayout::Full => ContextLayout::Split,
191 ContextLayout::Split => ContextLayout::Bottom,
192 ContextLayout::Bottom => ContextLayout::Full,
193 }
194 }
195
196 pub fn label(self) -> &'static str {
197 match self {
198 ContextLayout::Full => "full",
199 ContextLayout::Split => "split",
200 ContextLayout::Bottom => "bottom",
201 }
202 }
203}
204
205#[derive(Debug, Clone, Default, Deserialize, Serialize)]
210#[serde(default)]
211pub struct ContextConfig {
212 pub enabled: bool,
215 pub projects_path: Option<PathBuf>,
217 pub context_window_tokens: Option<u64>,
220 pub model_context_window_tokens: BTreeMap<String, u64>,
223 pub layout: ContextLayout,
225}
226
227impl ContextConfig {
228 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
229 model
230 .and_then(|model| self.model_context_window_tokens.get(model).copied())
231 .filter(|tokens| *tokens > 0)
232 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
233 }
234}
235
236#[derive(Debug, Clone, Deserialize, Serialize)]
237#[serde(default)]
238pub struct AnthropicConfig {
239 pub enabled: bool,
240 pub credentials_path: Option<PathBuf>,
243 pub accounts: Vec<AnthropicAccount>,
247 pub accounts_dir: Option<PathBuf>,
255 pub show_default_account: bool,
261 pub desktop_profiles_dir: Option<PathBuf>,
267}
268
269impl Default for AnthropicConfig {
270 fn default() -> Self {
271 Self {
272 enabled: true,
273 credentials_path: None,
274 accounts: Vec::new(),
275 accounts_dir: None,
276 show_default_account: true,
277 desktop_profiles_dir: None,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
292pub struct AnthropicAccount {
293 pub label: String,
296 pub credentials_path: PathBuf,
300}
301
302impl AnthropicAccount {
303 pub fn config_dir(&self) -> PathBuf {
308 self.credentials_path
309 .parent()
310 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
311 }
312}
313
314impl AnthropicConfig {
315 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
321 let mut out = self.accounts.clone();
322 if let Some(dir) = &self.accounts_dir {
323 for acct in discover_accounts(dir) {
324 if !out.iter().any(|a| a.label == acct.label) {
325 out.push(acct);
326 }
327 }
328 }
329 out
330 }
331
332 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
337 validate_account_label(label)?;
338 let all = self.all_accounts();
339 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
340 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
341 AppError::Credentials(format!(
342 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
343 known labels: {known:?}"
344 ))
345 })
346 }
347
348 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
359 let active = crate::anthropic::cli_account::home_claude_json()
360 .ok()
361 .and_then(|path| {
362 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
363 });
364 self.account_target_with(label, active.as_deref())
365 }
366
367 pub fn account_target_with(
375 &self,
376 label: &str,
377 cli_active: Option<&str>,
378 ) -> Result<(CredsTarget, Cache)> {
379 self.account_target_probing(label, cli_active, |path| path.exists())
380 }
381
382 pub fn account_target_probing(
402 &self,
403 label: &str,
404 cli_active: Option<&str>,
405 exists: impl Fn(&Path) -> bool,
406 ) -> Result<(CredsTarget, Cache)> {
407 let account = self.account(label)?;
408 let cache = Cache::for_vendor_account("anthropic", label)?;
409 if cli_active == Some(label) && !exists(&account.credentials_path) {
410 return Ok((
411 CredsTarget::Default(crate::anthropic::creds::default_path()?),
412 cache,
413 ));
414 }
415 Ok((
416 CredsTarget::Named {
417 config_dir: account.config_dir(),
418 path: account.credentials_path,
419 },
420 cache,
421 ))
422 }
423}
424
425pub fn validate_account_label(label: &str) -> Result<()> {
431 validate_account_label_for("anthropic", label)
432}
433
434fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
435 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
436 let bad = label.is_empty()
437 || label == "."
438 || label == ".."
439 || label.contains(['/', '\\'])
440 || label.contains(':')
441 || label.chars().any(char::is_control)
442 || RESERVED.contains(&label);
443 if bad {
444 return Err(AppError::Credentials(format!(
445 "invalid {vendor} account label {label:?}: must be a non-empty name \
446 without path separators, drive prefixes, control characters, or reserved cache names"
447 )));
448 }
449 Ok(())
450}
451
452fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
460 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
461 return Vec::new();
462 };
463 let mut found: Vec<AnthropicAccount> = entries
464 .flatten()
465 .filter_map(|entry| {
466 let path = entry.path();
467 if !path.is_dir() {
468 return None;
469 }
470 let label = path.file_name()?.to_str()?.to_string();
471 validate_account_label(&label).ok()?;
472 Some(AnthropicAccount {
473 label,
474 credentials_path: path.join(".credentials.json"),
475 })
476 })
477 .collect();
478 found.sort_by(|a, b| a.label.cmp(&b.label));
479 found
480}
481
482pub fn tildify(path: &Path, home: &Path) -> String {
486 path.strip_prefix(home)
487 .map(|rest| {
488 let rendered = rest.display().to_string();
489 #[cfg(windows)]
492 let rendered = rendered.replace('\\', "/");
493 format!("~/{rendered}")
494 })
495 .unwrap_or_else(|_| path.display().to_string())
496}
497
498pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
503 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
504 base.join("accounts").join(label).join(".credentials.json")
505}
506
507pub fn add_anthropic_account_to_doc(
513 doc: &mut toml_edit::DocumentMut,
514 label: &str,
515 credentials_path: &str,
516) -> Result<()> {
517 use toml_edit::{Item, Table, value};
518
519 validate_account_label(label)?;
520
521 let anthropic = doc
522 .entry("anthropic")
523 .or_insert_with(|| Item::Table(Table::new()));
524 let anthropic = anthropic
525 .as_table_mut()
526 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
527
528 let accounts = anthropic
529 .entry("accounts")
530 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
531 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
532 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
533 })?;
534
535 let exists = accounts
536 .iter()
537 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
538 if exists {
539 return Err(AppError::Credentials(format!(
540 "anthropic account {label:?} already exists in config.toml"
541 )));
542 }
543
544 let mut table = Table::new();
545 table["label"] = value(label);
546 table["credentials_path"] = value(credentials_path);
547 accounts.push(table);
548 Ok(())
549}
550
551pub(crate) fn set_bool(
555 doc: &mut toml_edit::DocumentMut,
556 section: &str,
557 key: &str,
558 new_value: bool,
559) -> Result<()> {
560 let table = doc
561 .entry(section)
562 .or_insert_with(toml_edit::table)
563 .as_table_mut()
564 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
565
566 if let Some(item) = table.get_mut(key)
567 && let Some(v) = item.as_value_mut()
568 {
569 let suffix = v.decor().suffix().cloned();
573 *v = toml_edit::Value::from(new_value);
574 v.decor_mut().set_prefix(" ");
575 if let Some(suffix) = suffix {
576 v.decor_mut().set_suffix(suffix);
577 }
578 return Ok(());
579 }
580 table.insert(key, toml_edit::value(new_value));
581 Ok(())
582}
583
584pub(crate) fn set_value(
590 doc: &mut toml_edit::DocumentMut,
591 section: &str,
592 key: &str,
593 new_value: Option<toml_edit::Value>,
594) -> Result<()> {
595 let table = doc
596 .entry(section)
597 .or_insert_with(toml_edit::table)
598 .as_table_mut()
599 .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
600
601 let Some(mut new_value) = new_value else {
602 table.remove(key);
603 return Ok(());
604 };
605 if let Some(item) = table.get_mut(key)
606 && let Some(v) = item.as_value_mut()
607 {
608 let suffix = v.decor().suffix().cloned();
609 new_value.decor_mut().set_prefix(" ");
610 if let Some(suffix) = suffix {
611 new_value.decor_mut().set_suffix(suffix);
612 }
613 *v = new_value;
614 return Ok(());
615 }
616 table.insert(key, toml_edit::Item::Value(new_value));
617 Ok(())
618}
619
620pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
626 let mut doc = read_config_document(path)?;
627 let before = doc.to_string();
628 set_value(&mut doc, "tray", key, value)?;
629 if doc.to_string() == before {
630 return Ok(());
631 }
632 write_config_document(path, &doc)
633}
634
635pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
639 let original = match std::fs::read_to_string(path) {
640 Ok(contents) => contents,
641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
642 Err(error) => return Err(AppError::io_at(path, error)),
643 };
644 if original.trim().is_empty() {
645 return Ok(toml_edit::DocumentMut::new());
646 }
647 original.parse().map_err(|e: toml_edit::TomlError| {
648 AppError::Other(format!("config.toml not parseable: {e}"))
649 })
650}
651
652pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
656 let bytes = doc.to_string();
657 crate::cache::atomic_write(path, bytes.as_bytes())?;
658
659 #[cfg(unix)]
660 {
661 if let Ok(meta) = std::fs::metadata(path) {
662 let mut perms = meta.permissions();
663 perms.set_mode(0o600);
664 let _ = std::fs::set_permissions(path, perms);
665 }
666 }
667 Ok(())
668}
669
670pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
678 let mut doc = read_config_document(path)?;
679 let before = doc.to_string();
680 let written: Vec<VendorId> = vendors
681 .iter()
682 .copied()
683 .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
684 .collect();
685 for vendor in &written {
686 set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
687 }
688 if doc.to_string() == before {
689 return Ok(written);
690 }
691 write_config_document(path, &doc)?;
692 Ok(written)
693}
694
695fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
706 doc.get(vendor.config_section())
707 .and_then(|section| section.get("enabled"))
708 .and_then(|enabled| enabled.as_bool())
709 == Some(false)
710}
711
712#[derive(Debug, Clone, Deserialize, Serialize)]
713#[serde(default)]
714pub struct OpenAiConfig {
715 pub enabled: bool,
716 pub codex_auth_path: Option<PathBuf>,
718 #[serde(default)]
723 pub accounts: Vec<OpenAiAccount>,
724 pub admin_key_env: String,
732}
733
734#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
745pub struct OpenAiAccount {
746 pub label: String,
749 pub codex_auth_path: PathBuf,
752}
753
754impl OpenAiConfig {
755 pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
759 let Some(label) = label else {
760 return match &self.codex_auth_path {
761 Some(path) => Ok(path.clone()),
762 None => crate::openai::creds::default_path(),
763 };
764 };
765 self.accounts
766 .iter()
767 .find(|account| account.label == label)
768 .map(|account| account.codex_auth_path.clone())
769 .ok_or_else(|| {
770 AppError::Credentials(format!(
771 "no OpenAI account named {label:?}. Add it under \
772 [[openai.accounts]], or drop --account to use the default login."
773 ))
774 })
775 }
776}
777
778impl Default for OpenAiConfig {
779 fn default() -> Self {
780 Self {
781 enabled: true,
782 codex_auth_path: None,
783 accounts: Vec::new(),
784 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
785 }
786 }
787}
788
789#[derive(Debug, Clone, Default, Deserialize, Serialize)]
793#[serde(default)]
794pub struct CopilotConfig {
795 pub enabled: bool,
796 pub gh_binary: Option<PathBuf>,
799}
800
801impl CopilotConfig {
802 pub fn resolve_token(&self) -> Result<String> {
803 self.resolve_token_with(
804 |name| std::env::var_os(name),
805 &crate::copilot::credentials::SystemGhAuthTokenRunner,
806 )
807 }
808
809 fn resolve_token_with(
810 &self,
811 environment: impl Fn(&str) -> Option<std::ffi::OsString>,
812 runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
813 ) -> Result<String> {
814 if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
815 let token = value.into_string().map_err(|_| {
816 AppError::Credentials(
817 "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
818 )
819 })?;
820 if !token.is_empty() {
821 return Ok(token);
822 }
823 }
824 crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
825 }
826}
827
828#[derive(Debug, Clone, Default, Deserialize, Serialize)]
829#[serde(default)]
830pub struct NousConfig {
831 pub enabled: bool,
832}
833
834#[derive(Debug, Clone, Deserialize, Serialize)]
835#[serde(default)]
836pub struct OpenCodeGoConfig {
837 pub enabled: bool,
838 pub api_key_env: String,
839 pub api_key: Option<String>,
840}
841
842#[derive(Debug, Clone, Deserialize, Serialize)]
848#[serde(default)]
849pub struct CommandCodeConfig {
850 pub enabled: bool,
851 pub auth_paths: Option<Vec<PathBuf>>,
852}
853
854impl Default for CommandCodeConfig {
855 fn default() -> Self {
856 Self {
857 enabled: true,
858 auth_paths: None,
859 }
860 }
861}
862
863#[derive(Debug, Clone, Deserialize, Serialize)]
868#[serde(default)]
869pub struct OllamaConfig {
870 pub enabled: bool,
871 pub api_key_env: String,
872 pub api_key: Option<String>,
873 pub plan: String,
876}
877
878impl Default for OllamaConfig {
879 fn default() -> Self {
880 Self {
881 enabled: false,
882 api_key_env: "OLLAMA_API_KEY".to_string(),
883 api_key: None,
884 plan: "pro".to_string(),
885 }
886 }
887}
888
889#[derive(Debug, Clone, Deserialize, Serialize)]
892#[serde(default)]
893pub struct OrcaRouterConfig {
894 pub enabled: bool,
895 pub api_key_env: String,
896 pub api_key: Option<String>,
897}
898
899impl Default for OrcaRouterConfig {
900 fn default() -> Self {
901 Self {
902 enabled: false,
903 api_key_env: "ORCAROUTER_API_KEY".to_string(),
904 api_key: None,
905 }
906 }
907}
908
909#[derive(Debug, Clone, Default, Deserialize, Serialize)]
916#[serde(default)]
917pub struct ModelStudioConfig {
918 pub enabled: bool,
921 pub config_dir: Option<PathBuf>,
924}
925
926impl Default for OpenCodeGoConfig {
927 fn default() -> Self {
928 Self {
929 enabled: false,
930 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
931 api_key: None,
932 }
933 }
934}
935
936#[derive(Debug, Clone, Deserialize, Serialize)]
937#[serde(default)]
938pub struct ZaiConfig {
939 pub enabled: bool,
940 pub api_key_env: String,
942 pub api_key: Option<String>,
945 pub plan_tier: Option<String>,
947}
948
949impl Default for ZaiConfig {
950 fn default() -> Self {
951 Self {
952 enabled: true,
953 api_key_env: "ZAI_API_KEY".to_string(),
954 api_key: None,
955 plan_tier: None,
956 }
957 }
958}
959
960#[derive(Debug, Clone, Deserialize, Serialize)]
961#[serde(default)]
962pub struct OpenRouterConfig {
963 pub enabled: bool,
964 pub accounts: Vec<OpenRouterAccount>,
967 pub show_default_account: bool,
971 pub api_key_env: String,
972 pub api_key: Option<String>,
973 pub headline: Headline,
985}
986
987impl Default for OpenRouterConfig {
988 fn default() -> Self {
989 Self {
990 enabled: true,
991 accounts: Vec::new(),
992 show_default_account: true,
993 api_key_env: "OPENROUTER_API_KEY".to_string(),
994 api_key: None,
995 headline: Headline::Percent,
996 }
997 }
998}
999
1000#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1003pub struct OpenRouterAccount {
1004 pub label: String,
1006 #[serde(default)]
1008 pub api_key_env: Option<String>,
1009 #[serde(default)]
1011 pub api_key: Option<String>,
1012}
1013
1014impl OpenRouterConfig {
1015 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
1018 validate_account_label_for("openrouter", label)?;
1019 self.accounts
1020 .iter()
1021 .find(|account| account.label == label)
1022 .ok_or_else(|| {
1023 let known: Vec<&str> = self
1024 .accounts
1025 .iter()
1026 .map(|account| account.label.as_str())
1027 .collect();
1028 AppError::Credentials(format!(
1029 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
1030 known labels: {known:?}"
1031 ))
1032 })
1033 }
1034
1035 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
1038 match label {
1039 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
1040 Some(label) => {
1041 let account = self.account(label)?;
1042 resolve_api_key_in_section(
1043 &format!("OpenRouter account {label:?}"),
1044 "[[openrouter.accounts]]",
1045 account.api_key_env.as_deref().unwrap_or(""),
1046 account.api_key.as_deref(),
1047 )
1048 }
1049 }
1050 }
1051}
1052
1053#[derive(Debug, Clone, Deserialize, Serialize)]
1054#[serde(default)]
1055pub struct DeepseekConfig {
1056 pub enabled: bool,
1057 pub api_key_env: String,
1058 pub api_key: Option<String>,
1059 pub display_limit: Option<f64>,
1062 pub headline: Headline,
1064}
1065
1066impl Default for DeepseekConfig {
1067 fn default() -> Self {
1068 Self {
1069 enabled: false,
1070 api_key_env: "DEEPSEEK_API_KEY".to_string(),
1071 api_key: None,
1072 display_limit: None,
1073 headline: Headline::Amount,
1074 }
1075 }
1076}
1077
1078#[derive(Debug, Clone, Deserialize, Serialize)]
1079#[serde(default)]
1080pub struct KimiConfig {
1081 pub enabled: bool,
1082 pub api_key_env: String,
1083 pub api_key: Option<String>,
1086 pub credentials_path: Option<PathBuf>,
1090 pub region: String,
1095}
1096
1097impl Default for KimiConfig {
1098 fn default() -> Self {
1099 Self {
1100 enabled: false,
1101 api_key_env: "KIMI_API_KEY".to_string(),
1102 api_key: None,
1103 credentials_path: None,
1104 region: "auto".to_string(),
1105 }
1106 }
1107}
1108
1109#[derive(Debug, Clone, Deserialize, Serialize)]
1110#[serde(default)]
1111pub struct KiloConfig {
1112 pub enabled: bool,
1113 pub api_key_env: String,
1114 pub api_key: Option<String>,
1115 pub organization_id: Option<String>,
1118 pub display_limit: Option<f64>,
1121 pub headline: Headline,
1123}
1124
1125impl Default for KiloConfig {
1126 fn default() -> Self {
1127 Self {
1130 enabled: false,
1131 api_key_env: "KILO_API_KEY".to_string(),
1132 api_key: None,
1133 organization_id: None,
1134 display_limit: None,
1135 headline: Headline::Amount,
1136 }
1137 }
1138}
1139
1140#[derive(Debug, Clone, Deserialize, Serialize)]
1141#[serde(default)]
1142pub struct NovitaConfig {
1143 pub enabled: bool,
1144 pub api_key_env: String,
1145 pub api_key: Option<String>,
1146 pub display_limit: Option<f64>,
1150 pub headline: Headline,
1152}
1153
1154impl Default for NovitaConfig {
1155 fn default() -> Self {
1156 Self {
1158 enabled: false,
1159 api_key_env: "NOVITA_API_KEY".to_string(),
1160 api_key: None,
1161 display_limit: None,
1162 headline: Headline::Amount,
1163 }
1164 }
1165}
1166
1167#[derive(Debug, Clone, Deserialize, Serialize)]
1168#[serde(default)]
1169pub struct MinimaxConfig {
1170 pub enabled: bool,
1171 pub api_key_env: String,
1172 pub api_key: Option<String>,
1173 pub region: String,
1179}
1180
1181impl Default for MinimaxConfig {
1182 fn default() -> Self {
1183 Self {
1185 enabled: false,
1186 api_key_env: "MINIMAX_API_KEY".to_string(),
1187 api_key: None,
1188 region: "global".to_string(),
1189 }
1190 }
1191}
1192
1193#[derive(Debug, Clone, Deserialize, Serialize)]
1194#[serde(default)]
1195pub struct MoonshotConfig {
1196 pub enabled: bool,
1197 pub api_key_env: String,
1198 pub api_key: Option<String>,
1199 pub region: String,
1201 pub display_limit: Option<f64>,
1204 pub headline: Headline,
1206}
1207
1208impl Default for MoonshotConfig {
1209 fn default() -> Self {
1210 Self {
1212 enabled: false,
1213 api_key_env: "MOONSHOT_API_KEY".to_string(),
1214 api_key: None,
1215 region: "global".to_string(),
1216 display_limit: None,
1217 headline: Headline::Amount,
1218 }
1219 }
1220}
1221
1222#[derive(Debug, Clone, Deserialize, Serialize)]
1223#[serde(default)]
1224pub struct GrokConfig {
1225 pub enabled: bool,
1226 pub api_key_env: String,
1228 pub api_key: Option<String>,
1229 pub team_id: Option<String>,
1232 pub display_limit: Option<f64>,
1234 pub headline: Headline,
1236}
1237
1238impl Default for GrokConfig {
1239 fn default() -> Self {
1240 Self {
1242 enabled: false,
1243 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1244 api_key: None,
1245 team_id: None,
1246 display_limit: None,
1247 headline: Headline::Amount,
1248 }
1249 }
1250}
1251
1252#[derive(Debug, Clone, Deserialize, Serialize)]
1260#[serde(default)]
1261pub struct SuperGrokConfig {
1262 pub enabled: bool,
1263 pub grok_binary: PathBuf,
1267 pub auth_path: Option<PathBuf>,
1270 pub config_path: Option<PathBuf>,
1271}
1272
1273impl Default for SuperGrokConfig {
1274 fn default() -> Self {
1275 Self {
1276 enabled: false,
1277 grok_binary: default_grok_binary(),
1278 auth_path: None,
1279 config_path: None,
1280 }
1281 }
1282}
1283
1284#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1290#[serde(default)]
1291pub struct GrokbotConfig {
1292 pub enabled: bool,
1295 pub secrets_path: Option<PathBuf>,
1300}
1301
1302fn default_grok_binary() -> PathBuf {
1303 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1304 let grok_home = std::env::var_os("GROK_HOME")
1305 .filter(|value| !value.is_empty())
1306 .map(PathBuf::from)
1307 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1308 grok_home
1309 .map(|home| home.join("bin").join(executable))
1310 .unwrap_or_else(|| PathBuf::from(executable))
1311}
1312
1313#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1321#[serde(default)]
1322pub struct AntigravityConfig {
1323 pub enabled: bool,
1324 pub oauth_client_id: Option<String>,
1326 pub oauth_client_secret: Option<String>,
1330}
1331
1332#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1342#[serde(default)]
1343pub struct CursorConfig {
1344 pub enabled: bool,
1345 pub db_path: Option<PathBuf>,
1349 pub agent_auth_path: Option<PathBuf>,
1354}
1355
1356#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1365#[serde(default)]
1366pub struct KiroConfig {
1367 pub enabled: bool,
1368 pub db_path: Option<PathBuf>,
1372}
1373
1374#[derive(Debug, Clone, Deserialize, Serialize)]
1375#[serde(default)]
1376pub struct AnthropicApiConfig {
1377 pub enabled: bool,
1378 pub api_key_env: String,
1381 pub api_key: Option<String>,
1382 pub monthly_limit: Option<f64>,
1385}
1386
1387impl Default for AnthropicApiConfig {
1388 fn default() -> Self {
1389 Self {
1391 enabled: false,
1392 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1393 api_key: None,
1394 monthly_limit: None,
1395 }
1396 }
1397}
1398
1399#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1409#[serde(default, remote = "Self")]
1410pub struct CustomProviderConfig {
1411 pub id: String,
1413 pub name: String,
1415 pub short_name: String,
1418 pub brand: Option<String>,
1421 pub enabled: bool,
1422 pub url: String,
1424 pub allow_http: bool,
1425 pub api_key_env: String,
1427 pub api_key: Option<String>,
1428 pub auth_header: String,
1430 pub auth_scheme: String,
1432 pub headers: BTreeMap<String, String>,
1434 pub plan: Option<String>,
1436 pub plan_path: Option<String>,
1438 pub cache_ttl_secs: u64,
1440 pub metrics: Vec<CustomMetricSpec>,
1441 pub texts: Vec<CustomTextSpec>,
1442}
1443
1444impl Default for CustomProviderConfig {
1445 fn default() -> Self {
1446 Self {
1447 id: String::new(),
1448 name: String::new(),
1449 short_name: String::new(),
1450 brand: None,
1451 enabled: false,
1452 url: String::new(),
1453 allow_http: false,
1454 api_key_env: String::new(),
1455 api_key: None,
1456 auth_header: "Authorization".to_string(),
1457 auth_scheme: "Bearer".to_string(),
1458 headers: BTreeMap::new(),
1459 plan: None,
1460 plan_path: None,
1461 cache_ttl_secs: 60,
1462 metrics: Vec::new(),
1463 texts: Vec::new(),
1464 }
1465 }
1466}
1467
1468impl<'de> Deserialize<'de> for CustomProviderConfig {
1473 fn deserialize<D: serde::Deserializer<'de>>(
1474 deserializer: D,
1475 ) -> std::result::Result<Self, D::Error> {
1476 let mut this = Self::deserialize(deserializer)?;
1477 if this.name.is_empty() {
1478 this.name = this.id.clone();
1479 }
1480 Ok(this)
1481 }
1482}
1483
1484impl Serialize for CustomProviderConfig {
1485 fn serialize<S: serde::Serializer>(
1486 &self,
1487 serializer: S,
1488 ) -> std::result::Result<S::Ok, S::Error> {
1489 Self::serialize(self, serializer)
1490 }
1491}
1492
1493#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1497#[serde(default)]
1498pub struct CustomMetricSpec {
1499 pub label: String,
1500 pub used: Option<String>,
1501 pub limit: Option<String>,
1502 pub percent: Option<String>,
1503 pub resets_at: Option<String>,
1505 pub window_secs: Option<u64>,
1507}
1508
1509#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1511#[serde(default)]
1512pub struct CustomTextSpec {
1513 pub label: String,
1514 pub value: String,
1515}
1516
1517impl CustomProviderConfig {
1518 pub fn section_label(&self) -> String {
1520 format!("[[custom]] id = {:?}", self.id)
1521 }
1522
1523 pub fn resolve_api_key(&self) -> Result<String> {
1526 if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1527 return Ok(key);
1528 }
1529 let advice = if self.api_key_env.is_empty() {
1530 "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1531 } else {
1532 format!("export {} or set `api_key`", self.api_key_env)
1533 };
1534 Err(AppError::Credentials(format!(
1535 "custom {}: no API key. Either {advice} under {} in {}.",
1536 self.id,
1537 self.section_label(),
1538 config_path_hint()
1539 )))
1540 }
1541
1542 pub fn cache_ttl(&self) -> std::time::Duration {
1543 std::time::Duration::from_secs(self.cache_ttl_secs)
1544 }
1545
1546 fn validate(&self, index: usize) -> Result<()> {
1550 if !is_valid_custom_id(&self.id) {
1551 return Err(AppError::Other(format!(
1552 "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1553 index + 1,
1554 self.id
1555 )));
1556 }
1557 let section = self.section_label();
1558 let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1559
1560 if VendorId::all().iter().any(|v| v.slug() == self.id) {
1561 return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1562 }
1563 let name_len = self.name.chars().count();
1564 if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1565 return Err(bad(
1566 "name must be 1 to 48 characters without control characters".into(),
1567 ));
1568 }
1569 if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1570 return Err(bad(format!(
1571 "short_name {:?} must be exactly 3 lowercase ASCII letters",
1572 self.short_name
1573 )));
1574 }
1575 if let Some(brand) = &self.brand
1576 && !VendorId::all().iter().any(|v| v.slug() == brand)
1577 {
1578 return Err(bad(format!(
1579 "brand {brand:?} must name a built-in vendor (it borrows that \
1580 vendor's mark); leave it unset to keep the short_name tag"
1581 )));
1582 }
1583 let url = reqwest::Url::parse(&self.url)
1584 .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1585 match url.scheme() {
1586 "https" => {}
1587 "http" if self.allow_http => {}
1588 "http" => {
1589 return Err(bad(
1590 "url must use https:// (set allow_http = true to permit http://)".into(),
1591 ));
1592 }
1593 other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1594 }
1595 if !url.username().is_empty() || url.password().is_some() {
1596 return Err(bad("url must not carry credentials (user:pass@)".into()));
1597 }
1598 if url.host_str().is_none() {
1599 return Err(bad("url has no host".into()));
1600 }
1601 if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1602 return Err(bad(format!(
1603 "api_key_env {:?} is not a valid environment variable name",
1604 self.api_key_env
1605 )));
1606 }
1607 validate_header_name(§ion, "auth_header", &self.auth_header)?;
1608 if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1609 return Err(bad(
1610 "auth_scheme contains characters that are not valid in an HTTP header".into(),
1611 ));
1612 }
1613 for (name, value) in &self.headers {
1614 validate_header_name(§ion, "headers", name)?;
1615 if name.eq_ignore_ascii_case(&self.auth_header) {
1616 return Err(bad(format!(
1617 "headers must not repeat auth_header {:?}",
1618 self.auth_header
1619 )));
1620 }
1621 if reqwest::header::HeaderValue::from_str(value).is_err() {
1622 return Err(bad(format!(
1623 "header {name:?} has a value that is not valid in an HTTP header"
1624 )));
1625 }
1626 }
1627 if let Some(plan) = &self.plan {
1628 validate_custom_label(§ion, "plan", plan)?;
1629 }
1630 if let Some(pointer) = &self.plan_path {
1631 validate_pointer(§ion, "plan_path", pointer)?;
1632 }
1633 if !(10..=3600).contains(&self.cache_ttl_secs) {
1634 return Err(bad(format!(
1635 "cache_ttl_secs must be between 10 and 3600, got {}",
1636 self.cache_ttl_secs
1637 )));
1638 }
1639 if self.metrics.is_empty() && self.texts.is_empty() {
1640 return Err(bad(
1641 "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1642 ));
1643 }
1644 let mut metric_labels = HashSet::new();
1645 for metric in &self.metrics {
1646 validate_custom_label(§ion, "metric label", &metric.label)?;
1647 if !metric_labels.insert(metric.label.as_str()) {
1648 return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1649 }
1650 let pair = (metric.used.is_some(), metric.limit.is_some());
1651 let well_formed = if metric.percent.is_some() {
1652 pair == (false, false)
1653 } else {
1654 pair == (true, true)
1655 };
1656 if !well_formed {
1657 return Err(bad(format!(
1658 "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1659 metric.label
1660 )));
1661 }
1662 for (field, pointer) in [
1663 ("used", &metric.used),
1664 ("limit", &metric.limit),
1665 ("percent", &metric.percent),
1666 ("resets_at", &metric.resets_at),
1667 ] {
1668 if let Some(pointer) = pointer {
1669 validate_pointer(§ion, field, pointer)?;
1670 }
1671 }
1672 if let Some(secs) = metric.window_secs
1673 && secs < 60
1674 {
1675 return Err(bad(format!(
1676 "metric {:?} window_secs must be at least 60, got {secs}",
1677 metric.label
1678 )));
1679 }
1680 }
1681 let mut text_labels = HashSet::new();
1682 for text in &self.texts {
1683 validate_custom_label(§ion, "text label", &text.label)?;
1684 if !text_labels.insert(text.label.as_str()) {
1685 return Err(bad(format!("duplicate text label {:?}", text.label)));
1686 }
1687 validate_pointer(§ion, "value", &text.value)?;
1688 }
1689 Ok(())
1690 }
1691}
1692
1693fn is_valid_custom_id(id: &str) -> bool {
1694 let bytes = id.as_bytes();
1695 let Some(&first) = bytes.first() else {
1696 return false;
1697 };
1698 bytes.len() <= 32
1699 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1700 && bytes
1701 .iter()
1702 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1703}
1704
1705fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1706 if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1707 return Err(AppError::Other(format!(
1708 "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1709 )));
1710 }
1711 Ok(())
1712}
1713
1714fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1715 let len = label.chars().count();
1716 if len == 0 || len > 64 || label.chars().any(char::is_control) {
1717 return Err(AppError::Other(format!(
1718 "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1719 )));
1720 }
1721 Ok(())
1722}
1723
1724fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1725 if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1726 return Err(AppError::Other(format!(
1727 "{section}: {field} {name:?} is not a valid HTTP header name"
1728 )));
1729 }
1730 Ok(())
1731}
1732
1733pub fn resolve_api_key(
1736 vendor_label: &str,
1737 env_var_name: &str,
1738 inline: Option<&str>,
1739) -> crate::error::Result<String> {
1740 let section = match vendor_label {
1741 "OpenCode Go" => "[opencode-go]".to_string(),
1742 _ => format!("[{}]", vendor_label.to_lowercase()),
1743 };
1744 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
1745}
1746
1747pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1751 if is_valid_env_var_name(env_var_name)
1752 && let Ok(v) = std::env::var(env_var_name)
1753 && !v.is_empty()
1754 {
1755 return Some(v);
1756 }
1757 inline.filter(|v| !v.is_empty()).map(str::to_string)
1758}
1759
1760fn resolve_api_key_in_section(
1761 vendor_label: &str,
1762 section: &str,
1763 env_var_name: &str,
1764 inline: Option<&str>,
1765) -> crate::error::Result<String> {
1766 if let Some(key) = optional_api_key(env_var_name, inline) {
1767 return Ok(key);
1768 }
1769 let valid_env_name = is_valid_env_var_name(env_var_name);
1770 let advice = if valid_env_name {
1771 "set an API key in a valid environment variable or set `api_key`"
1772 } else {
1773 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1774 };
1775 Err(crate::error::AppError::Credentials(format!(
1776 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1777 config_path_hint()
1778 )))
1779}
1780
1781pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1782 let mut chars = name.chars();
1783 let Some(first) = chars.next() else {
1784 return false;
1785 };
1786 (first.is_ascii_alphabetic() || first == '_')
1787 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1788}
1789
1790impl Config {
1791 pub fn load() -> Result<Self> {
1794 let Some(path) = resolved_path() else {
1795 return Ok(Self::default());
1796 };
1797 Self::load_from(&path)
1798 }
1799
1800 pub fn load_from(path: &std::path::Path) -> Result<Self> {
1801 match std::fs::read_to_string(path) {
1802 Ok(s) => {
1803 let mut config: Self = toml::from_str(&s)?;
1804 config.expand_paths();
1808 config.validate()?;
1809 #[cfg(unix)]
1810 config.protect_inline_secrets(path)?;
1811 crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1815 Ok(config)
1816 }
1817 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1818 Err(e) => Err(AppError::io_at(path, e)),
1819 }
1820 }
1821
1822 fn expand_paths(&mut self) {
1823 expand_tilde_opt(&mut self.context.projects_path);
1824 expand_tilde_opt(&mut self.anthropic.credentials_path);
1825 expand_tilde_opt(&mut self.anthropic.accounts_dir);
1826 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1827 expand_tilde_opt(&mut self.openai.codex_auth_path);
1828 expand_tilde_opt(&mut self.cursor.db_path);
1829 expand_tilde_opt(&mut self.cursor.agent_auth_path);
1830 expand_tilde_opt(&mut self.kiro.db_path);
1831 expand_tilde_opt(&mut self.kimi.credentials_path);
1832 expand_tilde_opt(&mut self.grokbot.secrets_path);
1833 expand_tilde_opt(&mut self.modelstudio.config_dir);
1834 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1835 expand_tilde_opt(&mut self.supergrok.auth_path);
1836 expand_tilde_opt(&mut self.supergrok.config_path);
1837 for account in &mut self.anthropic.accounts {
1838 account.credentials_path = expand_tilde(&account.credentials_path);
1839 }
1840 for account in &mut self.openai.accounts {
1841 account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1842 }
1843 }
1844
1845 #[cfg(unix)]
1849 fn has_inline_secrets(&self) -> bool {
1850 [
1851 self.zai.api_key.as_deref(),
1852 self.openrouter.api_key.as_deref(),
1853 self.deepseek.api_key.as_deref(),
1854 self.kimi.api_key.as_deref(),
1855 self.kilo.api_key.as_deref(),
1856 self.novita.api_key.as_deref(),
1857 self.minimax.api_key.as_deref(),
1858 self.moonshot.api_key.as_deref(),
1859 self.grok.api_key.as_deref(),
1860 self.anthropic_api.api_key.as_deref(),
1861 self.opencode_go.api_key.as_deref(),
1862 self.orcarouter.api_key.as_deref(),
1863 self.antigravity.oauth_client_secret.as_deref(),
1864 ]
1865 .into_iter()
1866 .chain(
1867 self.openrouter
1868 .accounts
1869 .iter()
1870 .map(|account| account.api_key.as_deref()),
1871 )
1872 .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
1873 .any(|key| key.is_some_and(|key| !key.is_empty()))
1874 }
1875
1876 fn custom_secret_env_vars(&self) -> Vec<String> {
1877 self.custom
1878 .iter()
1879 .filter(|c| !c.api_key_env.is_empty())
1880 .map(|c| c.api_key_env.clone())
1881 .collect()
1882 }
1883
1884 pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
1886 self.custom.iter().filter(|c| c.enabled)
1887 }
1888
1889 pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
1891 self.custom.iter().find(|c| c.id == id)
1892 }
1893
1894 #[cfg(unix)]
1895 fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1896 if !self.has_inline_secrets() {
1897 return Ok(());
1898 }
1899
1900 let metadata = std::fs::metadata(path).map_err(|_| {
1901 AppError::Credentials(format!(
1902 "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1903 path.display()
1904 ))
1905 })?;
1906 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1907 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1908 AppError::Credentials(format!(
1909 "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",
1910 path.display()
1911 ))
1912 })?;
1913 }
1914 Ok(())
1915 }
1916
1917 pub fn is_enabled(&self, id: VendorId) -> bool {
1918 match id {
1919 VendorId::Anthropic => self.anthropic.enabled,
1920 VendorId::AnthropicApi => self.anthropic_api.enabled,
1921 VendorId::Openai => self.openai.enabled,
1922 VendorId::Copilot => self.copilot.enabled,
1923 VendorId::Zai => self.zai.enabled,
1924 VendorId::Openrouter => self.openrouter.enabled,
1925 VendorId::Deepseek => self.deepseek.enabled,
1926 VendorId::Kimi => self.kimi.enabled,
1927 VendorId::Kilo => self.kilo.enabled,
1928 VendorId::Novita => self.novita.enabled,
1929 VendorId::Moonshot => self.moonshot.enabled,
1930 VendorId::Grok => self.grok.enabled,
1931 VendorId::Supergrok => self.supergrok.enabled,
1932 VendorId::Grokbot => self.grokbot.enabled,
1933 VendorId::Antigravity => self.antigravity.enabled,
1934 VendorId::Cursor => self.cursor.enabled,
1935 VendorId::Minimax => self.minimax.enabled,
1936 VendorId::Kiro => self.kiro.enabled,
1937 VendorId::NousResearch => self.nous.enabled,
1938 VendorId::OpenCodeGo => self.opencode_go.enabled,
1939 VendorId::CommandCode => self.commandcode.enabled,
1940 VendorId::Ollama => self.ollama.enabled,
1941 VendorId::OrcaRouter => self.orcarouter.enabled,
1942 VendorId::ModelStudio => self.modelstudio.enabled,
1943 }
1944 }
1945
1946 pub fn api_key_env_for(&self, id: VendorId) -> &str {
1953 match id {
1954 VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1955 VendorId::Zai => &self.zai.api_key_env,
1956 VendorId::Openrouter => &self.openrouter.api_key_env,
1957 VendorId::Deepseek => &self.deepseek.api_key_env,
1958 VendorId::Kimi => &self.kimi.api_key_env,
1959 VendorId::Kilo => &self.kilo.api_key_env,
1960 VendorId::Novita => &self.novita.api_key_env,
1961 VendorId::Moonshot => &self.moonshot.api_key_env,
1962 VendorId::Grok => &self.grok.api_key_env,
1963 VendorId::Minimax => &self.minimax.api_key_env,
1964 VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1965 VendorId::Ollama => &self.ollama.api_key_env,
1966 VendorId::OrcaRouter => &self.orcarouter.api_key_env,
1967 VendorId::Anthropic
1970 | VendorId::Openai
1971 | VendorId::Copilot
1972 | VendorId::Supergrok
1973 | VendorId::Grokbot
1974 | VendorId::Antigravity
1975 | VendorId::Cursor
1976 | VendorId::Kiro
1977 | VendorId::NousResearch
1978 | VendorId::CommandCode
1979 | VendorId::ModelStudio => id.api_key_env(),
1980 }
1981 }
1982
1983 pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1987 let raw = match id {
1988 VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1989 VendorId::Zai => self.zai.api_key.as_deref(),
1990 VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1991 VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1992 VendorId::Kimi => self.kimi.api_key.as_deref(),
1993 VendorId::Kilo => self.kilo.api_key.as_deref(),
1994 VendorId::Novita => self.novita.api_key.as_deref(),
1995 VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1996 VendorId::Grok => self.grok.api_key.as_deref(),
1997 VendorId::Minimax => self.minimax.api_key.as_deref(),
1998 VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1999 VendorId::Ollama => self.ollama.api_key.as_deref(),
2000 VendorId::OrcaRouter => self.orcarouter.api_key.as_deref(),
2001 VendorId::Anthropic
2002 | VendorId::Openai
2003 | VendorId::Copilot
2004 | VendorId::Supergrok
2005 | VendorId::Grokbot
2006 | VendorId::Antigravity
2007 | VendorId::Cursor
2008 | VendorId::Kiro
2009 | VendorId::NousResearch
2010 | VendorId::CommandCode
2011 | VendorId::ModelStudio => None,
2012 };
2013 raw.filter(|key| !key.is_empty())
2014 }
2015
2016 pub fn display_prefs(&self, vendor: VendorId) -> DisplayPrefs {
2021 match vendor {
2022 VendorId::Deepseek => {
2023 DisplayPrefs::balance(self.deepseek.display_limit, self.deepseek.headline)
2024 }
2025 VendorId::Kilo => DisplayPrefs::balance(self.kilo.display_limit, self.kilo.headline),
2026 VendorId::Novita => {
2027 DisplayPrefs::balance(self.novita.display_limit, self.novita.headline)
2028 }
2029 VendorId::Moonshot => {
2030 DisplayPrefs::balance(self.moonshot.display_limit, self.moonshot.headline)
2031 }
2032 VendorId::Grok => DisplayPrefs::balance(self.grok.display_limit, self.grok.headline),
2033 VendorId::Openrouter => DisplayPrefs::balance(None, self.openrouter.headline),
2036 _ => DisplayPrefs::default(),
2037 }
2038 }
2039
2040 pub fn enabled_vendors(&self) -> Vec<VendorId> {
2041 VendorId::all()
2042 .iter()
2043 .copied()
2044 .filter(|id| self.is_enabled(*id))
2045 .collect()
2046 }
2047
2048 pub fn validate(&self) -> Result<()> {
2052 if let Some(minutes) = self.tray.refresh_minutes
2053 && !TRAY_REFRESH_MINUTES.contains(&minutes)
2054 {
2055 return Err(AppError::Other(format!(
2056 "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
2057 )));
2058 }
2059 if self.context.context_window_tokens == Some(0) {
2060 return Err(AppError::Other(
2061 "[context] context_window_tokens must be greater than zero".into(),
2062 ));
2063 }
2064 for (model, tokens) in &self.context.model_context_window_tokens {
2065 if model.trim().is_empty() {
2066 return Err(AppError::Other(
2067 "[context] model_context_window_tokens keys must not be empty".into(),
2068 ));
2069 }
2070 if *tokens == 0 {
2071 return Err(AppError::Other(format!(
2072 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
2073 )));
2074 }
2075 }
2076 if let Some(limit) = self.anthropic_api.monthly_limit
2077 && (!limit.is_finite() || limit <= 0.0)
2078 {
2079 return Err(AppError::Other(
2080 "[anthropic_api] monthly_limit must be finite and greater than zero; \
2081 remove it to show spend without a limit"
2082 .into(),
2083 ));
2084 }
2085 for (section, limit) in [
2089 ("deepseek", self.deepseek.display_limit),
2090 ("kilo", self.kilo.display_limit),
2091 ("novita", self.novita.display_limit),
2092 ("moonshot", self.moonshot.display_limit),
2093 ("grok", self.grok.display_limit),
2094 ] {
2095 if let Some(limit) = limit
2096 && (!limit.is_finite() || limit <= 0.0)
2097 {
2098 return Err(AppError::Other(format!(
2099 "[{section}] display_limit must be finite and greater than zero; \
2100 remove it to show the balance without a limit"
2101 )));
2102 }
2103 }
2104 if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
2105 && !self.kimi.region.eq_ignore_ascii_case("auto")
2106 {
2107 return Err(AppError::Other(format!(
2108 "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
2109 self.kimi.region
2110 )));
2111 }
2112 if !self.minimax.region.eq_ignore_ascii_case("global")
2113 && !self.minimax.region.eq_ignore_ascii_case("cn")
2114 {
2115 return Err(AppError::Other(format!(
2116 "[minimax] region must be \"global\" or \"cn\", got {:?}",
2117 self.minimax.region
2118 )));
2119 }
2120 if self.supergrok.grok_binary.as_os_str().is_empty() {
2121 return Err(AppError::Other(
2122 "[supergrok] grok_binary must not be empty".into(),
2123 ));
2124 }
2125 let mut labels = HashSet::new();
2126 for account in &self.anthropic.accounts {
2127 validate_account_label(&account.label)?;
2128 if !labels.insert(&account.label) {
2129 return Err(AppError::Credentials(format!(
2130 "duplicate anthropic account label {:?}",
2131 account.label
2132 )));
2133 }
2134 }
2135 let mut openai_labels = HashSet::new();
2136 for account in &self.openai.accounts {
2137 validate_account_label_for("openai", &account.label)?;
2138 if !openai_labels.insert(&account.label) {
2139 return Err(AppError::Credentials(format!(
2140 "duplicate openai account label {:?}",
2141 account.label
2142 )));
2143 }
2144 }
2145 let mut openrouter_labels = HashSet::new();
2146 for account in &self.openrouter.accounts {
2147 validate_account_label_for("openrouter", &account.label)?;
2148 if !openrouter_labels.insert(&account.label) {
2149 return Err(AppError::Credentials(format!(
2150 "duplicate openrouter account label {:?}",
2151 account.label
2152 )));
2153 }
2154 let has_env = account
2155 .api_key_env
2156 .as_deref()
2157 .is_some_and(|name| !name.is_empty());
2158 let has_inline = account
2159 .api_key
2160 .as_deref()
2161 .is_some_and(|key| !key.is_empty());
2162 if !has_env && !has_inline {
2163 return Err(AppError::Credentials(format!(
2164 "openrouter account {:?} must set api_key_env or api_key",
2165 account.label
2166 )));
2167 }
2168 }
2169 self.validate_custom()
2170 }
2171
2172 fn validate_custom(&self) -> Result<()> {
2176 let mut ids = HashSet::new();
2177 let mut short_names: HashSet<&str> =
2178 VendorId::all().iter().map(|v| v.short_name()).collect();
2179 for (index, custom) in self.custom.iter().enumerate() {
2180 custom.validate(index)?;
2181 if !ids.insert(custom.id.as_str()) {
2182 return Err(AppError::Other(format!(
2183 "{}: duplicate id",
2184 custom.section_label()
2185 )));
2186 }
2187 if !short_names.insert(custom.short_name.as_str()) {
2188 return Err(AppError::Other(format!(
2189 "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2190 custom.section_label(),
2191 custom.short_name
2192 )));
2193 }
2194 }
2195 Ok(())
2196 }
2197}
2198
2199#[cfg(unix)]
2200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2201enum InlineKeyPermissionDecision {
2202 Ok,
2203 Tighten,
2204}
2205
2206#[cfg(unix)]
2207fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2208 if mode & 0o077 == 0 {
2209 InlineKeyPermissionDecision::Ok
2210 } else {
2211 InlineKeyPermissionDecision::Tighten
2212 }
2213}
2214
2215pub fn default_path() -> Option<PathBuf> {
2216 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2217 Some(proj.config_dir().join("config.toml"))
2218}
2219
2220fn legacy_xdg_path() -> Option<PathBuf> {
2225 let home = crate::cache::home_dir().ok()?;
2226 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2227}
2228
2229pub fn resolved_path() -> Option<PathBuf> {
2240 if let Some(path) = override_path() {
2241 return Some(path);
2242 }
2243 let canonical = default_path();
2244 if let Some(p) = &canonical
2245 && p.exists()
2246 {
2247 return canonical;
2248 }
2249 if let Some(legacy) = legacy_xdg_path()
2250 && legacy.exists()
2251 {
2252 return Some(legacy);
2253 }
2254 canonical
2255}
2256
2257static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2258
2259pub fn set_override_path(path: &std::path::Path) {
2265 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2266 *slot = Some(path.to_path_buf());
2267 }
2268}
2269
2270#[doc(hidden)]
2273pub fn clear_override_path() {
2274 if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2275 *slot = None;
2276 }
2277}
2278
2279fn override_path() -> Option<PathBuf> {
2280 PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2281}
2282
2283#[doc(hidden)]
2289pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2290 #[cfg(unix)]
2291 {
2292 use std::os::unix::ffi::{OsStrExt, OsStringExt};
2293 let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2294 Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2295 }
2296 #[cfg(windows)]
2297 {
2298 use std::os::windows::ffi::{OsStrExt, OsStringExt};
2299 const PREFIX: &[u16] = &[
2300 b'-' as u16,
2301 b'-' as u16,
2302 b'c' as u16,
2303 b'o' as u16,
2304 b'n' as u16,
2305 b'f' as u16,
2306 b'i' as u16,
2307 b'g' as u16,
2308 b'=' as u16,
2309 ];
2310 let wide: Vec<u16> = arg.encode_wide().collect();
2311 let rest = wide.strip_prefix(PREFIX)?;
2312 Some(std::ffi::OsString::from_wide(rest).into())
2313 }
2314 #[cfg(not(any(unix, windows)))]
2315 {
2316 Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2317 }
2318}
2319
2320fn expand_tilde(p: &std::path::Path) -> PathBuf {
2323 let Some(s) = p.to_str() else {
2324 return p.to_path_buf();
2325 };
2326 let rest = if s == "~" {
2327 ""
2328 } else if let Some(r) = s.strip_prefix("~/") {
2329 r
2330 } else {
2331 return p.to_path_buf();
2332 };
2333 match crate::cache::home_dir() {
2334 Ok(home) if rest.is_empty() => home,
2335 Ok(home) => home.join(rest),
2336 Err(_) => p.to_path_buf(),
2337 }
2338}
2339
2340fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2341 if let Some(inner) = p.as_ref() {
2342 *p = Some(expand_tilde(inner));
2343 }
2344}
2345
2346pub fn config_path_hint() -> String {
2351 resolved_path()
2352 .map(|p| p.display().to_string())
2353 .unwrap_or_else(|| "config.toml".to_string())
2354}
2355
2356#[cfg(test)]
2357mod tests {
2358 use super::*;
2359 use std::io::Write;
2360 use tempfile::NamedTempFile;
2361
2362 #[cfg(unix)]
2363 use std::os::unix::fs::{MetadataExt, PermissionsExt};
2364
2365 fn write_toml(s: &str) -> NamedTempFile {
2366 let mut f = NamedTempFile::new().unwrap();
2367 f.write_all(s.as_bytes()).unwrap();
2368 f.flush().unwrap();
2369 f
2370 }
2371
2372 #[test]
2376 fn openai_without_accounts_resolves_the_singular_path() {
2377 let explicit = OpenAiConfig {
2378 codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2379 ..OpenAiConfig::default()
2380 };
2381 assert_eq!(
2382 explicit.resolve_auth_path(None).unwrap(),
2383 PathBuf::from("/tmp/codex/auth.json")
2384 );
2385
2386 let bare = OpenAiConfig::default();
2387 assert_eq!(
2388 bare.resolve_auth_path(None).unwrap(),
2389 crate::openai::creds::default_path().unwrap(),
2390 "no codex_auth_path must still mean ~/.codex/auth.json"
2391 );
2392 }
2393
2394 #[test]
2397 fn openai_named_accounts_resolve_their_own_auth_file() {
2398 let config: Config = toml::from_str(
2399 r#"
2400 [openai]
2401 codex_auth_path = "/tmp/personal/auth.json"
2402 [[openai.accounts]]
2403 label = "work"
2404 codex_auth_path = "/tmp/work/auth.json"
2405 "#,
2406 )
2407 .unwrap();
2408
2409 assert_eq!(
2410 config.openai.resolve_auth_path(Some("work")).unwrap(),
2411 PathBuf::from("/tmp/work/auth.json")
2412 );
2413 assert_eq!(
2414 config.openai.resolve_auth_path(None).unwrap(),
2415 PathBuf::from("/tmp/personal/auth.json")
2416 );
2417 }
2418
2419 #[test]
2422 fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2423 let config = OpenAiConfig {
2424 codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2425 accounts: vec![OpenAiAccount {
2426 label: "work".into(),
2427 codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2428 }],
2429 ..OpenAiConfig::default()
2430 };
2431 let err = config
2432 .resolve_auth_path(Some("nope"))
2433 .unwrap_err()
2434 .to_string();
2435 assert!(err.contains("nope"), "{err}");
2436 assert!(err.contains("[[openai.accounts]]"), "{err}");
2437 }
2438
2439 #[test]
2440 fn defaults_enable_only_the_five_core_vendors() {
2441 let c = Config::default();
2442 assert!(c.is_enabled(VendorId::Anthropic));
2443 assert!(c.is_enabled(VendorId::Openai));
2444 assert!(c.is_enabled(VendorId::Zai));
2445 assert!(c.is_enabled(VendorId::Openrouter));
2446 assert!(c.is_enabled(VendorId::CommandCode));
2447 for opt_in in [
2448 VendorId::AnthropicApi,
2449 VendorId::Copilot,
2450 VendorId::Deepseek,
2451 VendorId::Kimi,
2452 VendorId::Kilo,
2453 VendorId::Novita,
2454 VendorId::Moonshot,
2455 VendorId::Grok,
2456 VendorId::Supergrok,
2457 VendorId::Grokbot,
2458 VendorId::Cursor,
2459 VendorId::Minimax,
2460 VendorId::Kiro,
2461 VendorId::OrcaRouter,
2462 VendorId::ModelStudio,
2463 ] {
2464 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2465 }
2466 assert_eq!(c.enabled_vendors().len(), 5);
2467 }
2468
2469 #[test]
2470 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2471 let config = Config::default();
2472 assert!(!config.is_enabled(VendorId::NousResearch));
2473 assert!(!config.is_enabled(VendorId::OpenCodeGo));
2474 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2475 assert!(config.opencode_go.api_key.is_none());
2476 assert!(!config.is_enabled(VendorId::Copilot));
2477 }
2478
2479 #[cfg(unix)]
2480 #[test]
2481 fn inline_credentials_are_protected() {
2482 let mut config = Config::default();
2483 config.opencode_go.api_key = Some("<redacted>".to_string());
2484 assert!(config.has_inline_secrets());
2485 }
2486
2487 #[test]
2488 fn antigravity_oauth_client_overrides_parse() {
2489 let config: Config = toml::from_str(
2490 "[antigravity]
2491enabled = true
2492oauth_client_id = \"test-client\"
2493oauth_client_secret = \"test-client-secret\"
2494",
2495 )
2496 .unwrap();
2497 assert!(config.antigravity.enabled);
2498 assert_eq!(
2499 config.antigravity.oauth_client_id.as_deref(),
2500 Some("test-client")
2501 );
2502 assert_eq!(
2503 config.antigravity.oauth_client_secret.as_deref(),
2504 Some("test-client-secret")
2505 );
2506 let bare: Config = toml::from_str(
2507 "[antigravity]
2508enabled = true
2509",
2510 )
2511 .unwrap();
2512 assert!(bare.antigravity.oauth_client_id.is_none());
2513 assert!(bare.antigravity.oauth_client_secret.is_none());
2514 }
2515
2516 #[cfg(unix)]
2517 #[test]
2518 fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2519 let mut config = Config::default();
2520 config.antigravity.oauth_client_id = Some("test-client".into());
2521 assert!(!config.has_inline_secrets());
2522 config.antigravity.oauth_client_secret = Some("<redacted>".into());
2523 assert!(config.has_inline_secrets());
2524 }
2525
2526 #[cfg(unix)]
2527 #[test]
2528 fn openrouter_named_inline_keys_receive_config_file_protection() {
2529 let mut config = Config::default();
2530 config.openrouter.accounts.push(OpenRouterAccount {
2531 label: "work".into(),
2532 api_key_env: None,
2533 api_key: Some("<redacted>".into()),
2534 });
2535 assert!(config.has_inline_secrets());
2536 }
2537
2538 #[test]
2539 fn missing_file_uses_defaults() {
2540 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2541 let c = Config::load_from(path).unwrap();
2542 assert!(c.is_enabled(VendorId::Anthropic));
2543 }
2544
2545 #[test]
2546 fn parses_full_config() {
2547 let f = write_toml(
2548 r#"
2549 [anthropic]
2550 enabled = true
2551
2552 [openai]
2553 enabled = false
2554 admin_key_env = "MY_ADMIN_KEY"
2555
2556 [zai]
2557 enabled = true
2558 api_key_env = "MY_ZAI"
2559 plan_tier = "pro"
2560
2561 [openrouter]
2562 enabled = false
2563 "#,
2564 );
2565 let c = Config::load_from(f.path()).unwrap();
2566 assert!(c.is_enabled(VendorId::Anthropic));
2567 assert!(!c.is_enabled(VendorId::Openai));
2568 assert!(c.is_enabled(VendorId::Zai));
2569 assert!(!c.is_enabled(VendorId::Openrouter));
2570 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2571 assert_eq!(c.zai.api_key_env, "MY_ZAI");
2572 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2573 assert!(c.openrouter.accounts.is_empty());
2574 assert!(c.openrouter.show_default_account);
2575 }
2576
2577 #[test]
2578 fn partial_config_falls_back_to_defaults() {
2579 let f = write_toml(
2580 r#"[openai]
2581enabled = false
2582"#,
2583 );
2584 let c = Config::load_from(f.path()).unwrap();
2585 assert!(!c.is_enabled(VendorId::Openai));
2586 assert!(c.is_enabled(VendorId::Anthropic));
2588 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2589 }
2590
2591 #[test]
2592 fn malformed_toml_returns_error() {
2593 let f = write_toml("this is not = = valid");
2594 assert!(Config::load_from(f.path()).is_err());
2595 }
2596
2597 #[cfg(unix)]
2598 #[test]
2599 fn load_from_tightens_world_readable_config_with_inline_api_key() {
2600 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2601 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2602
2603 Config::load_from(file.path()).unwrap();
2604
2605 assert_eq!(
2606 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2607 0o600
2608 );
2609 }
2610
2611 #[cfg(unix)]
2612 #[test]
2613 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2614 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2615 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2616
2617 Config::load_from(file.path()).unwrap();
2618
2619 assert_eq!(
2620 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2621 0o644
2622 );
2623 }
2624
2625 #[cfg(unix)]
2626 #[test]
2627 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2628 assert_eq!(
2629 inline_key_permission_decision(0o600),
2630 InlineKeyPermissionDecision::Ok
2631 );
2632 assert_eq!(
2633 inline_key_permission_decision(0o640),
2634 InlineKeyPermissionDecision::Tighten
2635 );
2636 assert_eq!(
2637 inline_key_permission_decision(0o604),
2638 InlineKeyPermissionDecision::Tighten
2639 );
2640 }
2641
2642 #[test]
2643 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2644 for value in ["0", "-1", "inf", "nan"] {
2645 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2646 let error = Config::load_from(file.path()).unwrap_err().to_string();
2647 assert!(error.contains("monthly_limit"), "value {value}: {error}");
2648 }
2649
2650 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2651 assert_eq!(
2652 Config::load_from(file.path())
2653 .unwrap()
2654 .anthropic_api
2655 .monthly_limit,
2656 Some(1000.0)
2657 );
2658 }
2659
2660 #[test]
2661 fn display_limit_must_be_positive_and_finite_on_every_balance_vendor() {
2662 for section in ["deepseek", "kilo", "novita", "moonshot", "grok"] {
2664 for value in ["0", "-1", "inf", "nan"] {
2665 let file = write_toml(&format!("[{section}]\ndisplay_limit = {value}\n"));
2666 let error = Config::load_from(file.path()).unwrap_err().to_string();
2667 assert!(
2668 error.contains(&format!("[{section}] display_limit")),
2669 "{section} = {value}: {error}"
2670 );
2671 }
2672 let file = write_toml(&format!("[{section}]\ndisplay_limit = 200\n"));
2673 let config = Config::load_from(file.path()).unwrap();
2674 assert_eq!(
2675 config.display_prefs(vendor_of(section)).display_limit,
2676 Some(200.0),
2677 "{section}"
2678 );
2679 }
2680 }
2681
2682 #[test]
2684 fn display_limit_is_absent_until_the_user_states_one() {
2685 let config = Config::default();
2686 for vendor in VendorId::all() {
2687 assert_eq!(
2688 config.display_prefs(*vendor).display_limit,
2689 None,
2690 "{vendor:?}"
2691 );
2692 }
2693 }
2694
2695 #[test]
2698 fn the_default_headline_follows_the_kind_of_vendor() {
2699 let config = Config::default();
2700 for vendor in [
2701 VendorId::Deepseek,
2702 VendorId::Kilo,
2703 VendorId::Novita,
2704 VendorId::Moonshot,
2705 VendorId::Grok,
2706 ] {
2707 assert_eq!(
2708 config.display_prefs(vendor).headline,
2709 Headline::Amount,
2710 "{vendor:?}"
2711 );
2712 }
2713 assert_eq!(
2714 config.display_prefs(VendorId::Openrouter).headline,
2715 Headline::Percent
2716 );
2717 assert_eq!(
2718 config.display_prefs(VendorId::Anthropic),
2719 DisplayPrefs::default()
2720 );
2721 }
2722
2723 #[test]
2724 fn the_headline_is_configurable_per_vendor_and_a_typo_is_loud() {
2725 let file = write_toml("[deepseek]\nheadline = \"percent\"\n");
2726 assert_eq!(
2727 Config::load_from(file.path())
2728 .unwrap()
2729 .display_prefs(VendorId::Deepseek)
2730 .headline,
2731 Headline::Percent
2732 );
2733
2734 let file = write_toml("[openrouter]\nheadline = \"amount\"\n");
2735 assert_eq!(
2736 Config::load_from(file.path())
2737 .unwrap()
2738 .display_prefs(VendorId::Openrouter)
2739 .headline,
2740 Headline::Amount
2741 );
2742
2743 let file = write_toml("[deepseek]\nheadline = \"dollars\"\n");
2744 let error = Config::load_from(file.path()).unwrap_err().to_string();
2745 assert!(error.contains("headline"), "{error}");
2746 }
2747
2748 #[test]
2754 fn openrouter_has_no_display_limit_to_be_ignored() {
2755 let file = write_toml("[openrouter]\ndisplay_limit = 200\nheadline = \"percent\"\n");
2756 let config = Config::load_from(file.path()).unwrap();
2757 let prefs = config.display_prefs(VendorId::Openrouter);
2758 assert_eq!(prefs.display_limit, None);
2759 assert_eq!(prefs.headline, Headline::Percent);
2760 }
2761
2762 #[test]
2765 fn a_display_limit_alone_leaves_the_headline_where_it_was() {
2766 let file = write_toml("[deepseek]\ndisplay_limit = 200\n");
2767 let prefs = Config::load_from(file.path())
2768 .unwrap()
2769 .display_prefs(VendorId::Deepseek);
2770 assert_eq!(prefs.display_limit, Some(200.0));
2771 assert_eq!(prefs.headline, Headline::Amount);
2772 }
2773
2774 fn vendor_of(section: &str) -> VendorId {
2775 VendorId::all()
2776 .iter()
2777 .copied()
2778 .find(|vendor| vendor.config_section() == section)
2779 .unwrap_or_else(|| panic!("no vendor for [{section}]"))
2780 }
2781
2782 #[test]
2783 fn minimax_region_accepts_only_known_instances() {
2784 for region in ["global", "GLOBAL", "cn", "CN"] {
2785 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2786 assert_eq!(
2787 Config::load_from(file.path()).unwrap().minimax.region,
2788 region
2789 );
2790 }
2791
2792 for region in ["", "china", "us"] {
2793 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2794 let error = Config::load_from(file.path()).unwrap_err().to_string();
2795 assert!(error.contains("[minimax] region"), "{error}");
2796 }
2797 }
2798
2799 #[test]
2800 fn kimi_region_accepts_auto_and_both_deployments() {
2801 for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
2802 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2803 assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
2804 }
2805
2806 for region in ["", "us", "oversea"] {
2807 let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2808 let error = Config::load_from(file.path()).unwrap_err().to_string();
2809 assert!(error.contains("[kimi] region"), "{error}");
2810 }
2811 }
2812
2813 #[test]
2814 fn kimi_defaults_to_auto_region_and_no_credential_override() {
2815 let defaults = KimiConfig::default();
2816 assert_eq!(defaults.region, "auto");
2817 assert_eq!(defaults.credentials_path, None);
2818 assert!(!defaults.enabled);
2819 }
2820
2821 #[test]
2822 fn kimi_credentials_path_expands_a_tilde() {
2823 let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
2824 let path = Config::load_from(file.path())
2825 .unwrap()
2826 .kimi
2827 .credentials_path
2828 .unwrap();
2829 assert!(!path.starts_with("~"), "{}", path.display());
2830 assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
2831 }
2832
2833 #[test]
2834 fn grokbot_is_opt_in_and_takes_no_api_key() {
2835 let defaults = GrokbotConfig::default();
2836 assert!(!defaults.enabled);
2837 assert_eq!(defaults.secrets_path, None);
2838 let config = Config::default();
2840 assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
2841 assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
2842
2843 let file = write_toml("[grokbot]\nenabled = true\n");
2844 let config = Config::load_from(file.path()).unwrap();
2845 assert!(config.is_enabled(VendorId::Grokbot));
2846 assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
2847 }
2848
2849 #[test]
2850 fn grokbot_secrets_path_expands_a_tilde() {
2851 let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
2852 let path = Config::load_from(file.path())
2853 .unwrap()
2854 .grokbot
2855 .secrets_path
2856 .unwrap();
2857 assert!(!path.starts_with("~"), "{}", path.display());
2858 assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
2859 }
2860
2861 #[test]
2862 fn modelstudio_is_opt_in_and_takes_no_api_key() {
2863 let defaults = ModelStudioConfig::default();
2864 assert!(!defaults.enabled);
2865 assert_eq!(defaults.config_dir, None);
2866 let config = Config::default();
2868 assert_eq!(config.api_key_env_for(VendorId::ModelStudio), "");
2869 assert_eq!(config.inline_api_key(VendorId::ModelStudio), None);
2870
2871 let file = write_toml("[modelstudio]\nenabled = true\n");
2872 let config = Config::load_from(file.path()).unwrap();
2873 assert!(config.is_enabled(VendorId::ModelStudio));
2874 assert!(config.enabled_vendors().contains(&VendorId::ModelStudio));
2875 }
2876
2877 #[test]
2878 fn modelstudio_config_dir_expands_a_tilde() {
2879 let file = write_toml("[modelstudio]\nconfig_dir = \"~/bl\"\n");
2880 let path = Config::load_from(file.path())
2881 .unwrap()
2882 .modelstudio
2883 .config_dir
2884 .unwrap();
2885 assert!(!path.starts_with("~"), "{}", path.display());
2886 assert!(path.ends_with("bl"), "{}", path.display());
2887 }
2888
2889 #[test]
2890 fn optional_api_key_reports_absence_instead_of_failing() {
2891 assert_eq!(
2892 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
2893 Some("inline".to_string())
2894 );
2895 assert_eq!(
2896 optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
2897 None
2898 );
2899 assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
2900 assert_eq!(
2903 optional_api_key("9INVALID", Some("inline")),
2904 Some("inline".to_string())
2905 );
2906 }
2907
2908 #[test]
2909 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
2910 let defaults = Config::default();
2911 assert!(!defaults.context.enabled);
2912 assert_eq!(
2913 defaults.context.window_tokens_for(Some("claude-test")),
2914 None
2915 );
2916
2917 let file = write_toml(
2918 r#"
2919 [context]
2920 enabled = true
2921 context_window_tokens = 200000
2922
2923 [context.model_context_window_tokens]
2924 claude-opus-1m = 1000000
2925 "claude exact id" = 300000
2926 "#,
2927 );
2928 let config = Config::load_from(file.path()).unwrap();
2929 assert!(config.context.enabled);
2930 assert_eq!(
2931 config.context.window_tokens_for(Some("claude-opus-1m")),
2932 Some(1_000_000)
2933 );
2934 assert_eq!(
2935 config.context.window_tokens_for(Some("claude exact id")),
2936 Some(300_000)
2937 );
2938 assert_eq!(
2939 config.context.window_tokens_for(Some("another-model")),
2940 Some(200_000)
2941 );
2942 }
2943
2944 #[test]
2945 fn context_layout_defaults_to_full_and_parses_each_variant() {
2946 assert_eq!(Config::default().context.layout, ContextLayout::Full);
2947 for (text, want) in [
2948 ("full", ContextLayout::Full),
2949 ("split", ContextLayout::Split),
2950 ("bottom", ContextLayout::Bottom),
2951 ] {
2952 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
2953 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
2954 }
2955 let file = write_toml("[context]\nlayout = \"floating\"\n");
2956 assert!(
2957 Config::load_from(file.path()).is_err(),
2958 "an unknown layout must be rejected, not silently defaulted"
2959 );
2960 }
2961
2962 #[test]
2963 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
2964 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
2965 for (text, want) in [
2966 ("sidebar", VendorBoxStyle::Sidebar),
2967 ("navbar", VendorBoxStyle::Navbar),
2968 ("none", VendorBoxStyle::None),
2969 ] {
2970 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
2971 assert_eq!(
2972 Config::load_from(file.path()).unwrap().ui.vendor_box(),
2973 want
2974 );
2975 }
2976 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
2977 assert!(
2978 Config::load_from(file.path()).is_err(),
2979 "an unknown vendor_box style must be rejected, not silently defaulted"
2980 );
2981 }
2982
2983 #[test]
2984 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
2985 for source in [
2986 "[context]\ncontext_window_tokens = 0\n",
2987 "[context.model_context_window_tokens]\nclaude = 0\n",
2988 "[context.model_context_window_tokens]\n\" \" = 200000\n",
2989 ] {
2990 let file = write_toml(source);
2991 let error = Config::load_from(file.path()).unwrap_err().to_string();
2992 assert!(error.contains("context"), "{error}");
2993 }
2994 }
2995
2996 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2998 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2999 M.lock().unwrap_or_else(|p| p.into_inner())
3000 }
3001
3002 #[test]
3003 fn resolve_api_key_prefers_env_over_inline() {
3004 let _g = env_guard();
3005 let var = "AI_USAGEBAR_TEST_ENV_WINS";
3007 unsafe { std::env::set_var(var, "from-env") };
3009 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
3010 unsafe { std::env::remove_var(var) };
3011 assert_eq!(got, "from-env");
3012 }
3013
3014 #[test]
3015 fn resolve_api_key_falls_back_to_inline() {
3016 let _g = env_guard();
3017 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
3018 unsafe { std::env::remove_var(var) };
3019 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
3020 assert_eq!(got, "inline-key");
3021 }
3022
3023 #[test]
3024 fn copilot_token_prefers_explicit_environment_over_gh_cli() {
3025 struct NeverRun;
3026 impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
3027 fn run(
3028 &self,
3029 _: &crate::copilot::credentials::GhAuthTokenCommand,
3030 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3031 panic!("environment override must not invoke gh")
3032 }
3033 }
3034
3035 let token = CopilotConfig::default()
3036 .resolve_token_with(
3037 |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
3038 &NeverRun,
3039 )
3040 .unwrap();
3041 assert_eq!(token, "from-environment");
3042 }
3043
3044 #[test]
3045 fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
3046 struct FailedGh;
3047 impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
3048 fn run(
3049 &self,
3050 _: &crate::copilot::credentials::GhAuthTokenCommand,
3051 ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
3052 Ok(crate::copilot::credentials::GhAuthTokenOutput {
3053 success: false,
3054 stdout: b"never-echo-gh-output".to_vec(),
3055 })
3056 }
3057 }
3058 let error = CopilotConfig::default()
3059 .resolve_token_with(|_| None, &FailedGh)
3060 .unwrap_err()
3061 .to_string();
3062 assert!(error.contains("gh auth login --web"));
3063 assert!(!error.contains("never-echo-gh-output"));
3064 }
3065
3066 #[test]
3067 fn resolve_api_key_errors_when_both_missing() {
3068 let _g = env_guard();
3069 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
3070 unsafe { std::env::remove_var(var) };
3071 let err = resolve_api_key("Zai", var, None).unwrap_err();
3072 match err {
3073 crate::error::AppError::Credentials(msg) => {
3074 assert!(
3075 msg.contains("api_key"),
3076 "error should suggest config field: {msg}"
3077 );
3078 }
3079 other => panic!("expected Credentials error, got {other:?}"),
3080 }
3081 }
3082
3083 #[test]
3084 fn resolve_api_key_uses_exact_opencode_go_section_name() {
3085 let _g = env_guard();
3086 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
3087 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
3088 let message = err.to_string();
3089 assert!(
3090 message.contains("[opencode-go]"),
3091 "wrong section hint: {message}"
3092 );
3093 assert!(
3094 !message.contains("[opencode go]"),
3095 "wrong section hint: {message}"
3096 );
3097 }
3098
3099 fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
3100 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
3101 M.lock().unwrap_or_else(|p| p.into_inner())
3102 }
3103
3104 struct ScopedPathOverride {
3110 _serial: std::sync::MutexGuard<'static, ()>,
3111 }
3112
3113 impl Drop for ScopedPathOverride {
3114 fn drop(&mut self) {
3115 clear_override_path();
3116 }
3117 }
3118
3119 fn scoped_path_override() -> ScopedPathOverride {
3120 ScopedPathOverride {
3121 _serial: path_override_guard(),
3122 }
3123 }
3124
3125 #[test]
3126 fn override_path_wins_over_canonical_and_legacy() {
3127 let _scoped = scoped_path_override();
3128 let file = NamedTempFile::new().unwrap();
3129 set_override_path(file.path());
3130 assert_eq!(resolved_path().as_deref(), Some(file.path()));
3131 assert_eq!(config_path_hint(), file.path().display().to_string());
3132 clear_override_path();
3133 let p = resolved_path().expect("a config path must resolve");
3135 assert!(p.ends_with("config.toml"));
3136 }
3137
3138 #[test]
3139 fn scoped_override_guard_clears_the_override_on_panic() {
3140 let hook = std::panic::take_hook();
3143 std::panic::set_hook(Box::new(|_| {}));
3144 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3145 let _scoped = scoped_path_override();
3146 set_override_path(std::path::Path::new("panicked-override.toml"));
3147 panic!("simulated mid-test failure");
3148 }))
3149 .is_err();
3150 std::panic::set_hook(hook);
3151 assert!(panicked, "the simulated failure must run");
3152 let _serial = path_override_guard();
3153 assert!(
3154 override_path().is_none(),
3155 "a panicking test must not leak the override into siblings"
3156 );
3157 }
3158
3159 #[test]
3160 fn config_path_hint_ends_with_config_toml() {
3161 let _g = path_override_guard();
3162 assert!(config_path_hint().ends_with("config.toml"));
3165 }
3166
3167 #[test]
3168 fn config_flag_value_splits_the_equals_form() {
3169 use std::ffi::OsStr;
3170 assert_eq!(
3171 config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
3172 Some(std::path::Path::new("work.toml"))
3173 );
3174 assert_eq!(
3175 config_flag_value(OsStr::new("--config=")).as_deref(),
3176 Some(std::path::Path::new(""))
3177 );
3178 assert_eq!(config_flag_value(OsStr::new("--config")), None);
3179 assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
3180 assert_eq!(config_flag_value(OsStr::new("account")), None);
3181 }
3182
3183 #[cfg(unix)]
3187 #[test]
3188 fn config_flag_value_keeps_undecodable_bytes_intact() {
3189 use std::ffi::OsString;
3190 use std::os::unix::ffi::{OsStrExt, OsStringExt};
3191 let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
3192 let value = config_flag_value(&raw).expect("prefix matches");
3193 assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
3194 }
3195
3196 #[cfg(windows)]
3197 #[test]
3198 fn config_flag_value_keeps_lone_surrogates_intact() {
3199 use std::ffi::OsString;
3200 use std::os::windows::ffi::{OsStrExt, OsStringExt};
3201 let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
3202 wide.push(0xDC00); wide.extend("x.toml".encode_utf16());
3204 let raw = OsString::from_wide(&wide);
3205 let value = config_flag_value(&raw).expect("prefix matches");
3206 let mut expected = vec![0xDC00u16];
3207 expected.extend("x.toml".encode_utf16());
3208 assert_eq!(
3209 value.as_os_str().encode_wide().collect::<Vec<_>>(),
3210 expected
3211 );
3212 }
3213
3214 #[test]
3215 fn resolve_api_key_treats_empty_env_as_unset() {
3216 let _g = env_guard();
3217 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
3218 unsafe { std::env::set_var(var, "") };
3219 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
3220 unsafe { std::env::remove_var(var) };
3221 assert_eq!(got, "inline");
3222 }
3223
3224 #[test]
3225 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
3226 let _g = env_guard();
3227 let bad = "sk-kimi-very-real-looking-pasted-secret";
3229 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
3230 let msg = err.to_string();
3231 assert!(
3232 msg.contains("invalid") && msg.contains("api_key_env"),
3233 "error should explain misconfiguration: {msg}"
3234 );
3235 assert!(
3236 !msg.contains(bad),
3237 "error must not echo the misconfigured value: {msg}"
3238 );
3239 assert!(msg.contains("valid environment variable name"));
3240 assert!(
3241 msg.contains("[kimi]"),
3242 "error should point at the lowercase TOML section: {msg}"
3243 );
3244 }
3245
3246 #[test]
3247 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
3248 let _g = env_guard();
3249 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
3250 assert_eq!(got, "inline-key");
3251 }
3252
3253 #[test]
3254 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
3255 let _g = env_guard();
3256 let pasted_secret = "sk_pasted_secret";
3259 unsafe { std::env::remove_var(pasted_secret) };
3260 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
3261 assert!(
3262 !err.to_string().contains(pasted_secret),
3263 "error must not echo configured api_key_env values"
3264 );
3265 }
3266
3267 #[test]
3268 fn is_valid_env_var_name_rules() {
3269 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
3271 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
3272 }
3273 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
3275 assert!(
3276 !is_valid_env_var_name(invalid),
3277 "{invalid} should be invalid"
3278 );
3279 }
3280 }
3281
3282 #[test]
3283 fn config_parses_with_inline_api_key_and_primary() {
3284 let f = write_toml(
3285 r#"
3286 [ui]
3287 primary = "openrouter"
3288
3289 [zai]
3290 enabled = true
3291 api_key_env = "MY_ZAI"
3292 api_key = "sk-zai-inline"
3293
3294 [openrouter]
3295 enabled = true
3296 api_key = "sk-or-inline"
3297 "#,
3298 );
3299 let c = Config::load_from(f.path()).unwrap();
3300 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3301 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3302 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3303 }
3304
3305 #[test]
3306 fn openrouter_named_accounts_preserve_the_default_contract() {
3307 let f = write_toml(
3308 r#"
3309 [openrouter]
3310 enabled = true
3311 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3312 api_key = "default-inline"
3313 show_default_account = false
3314
3315 [[openrouter.accounts]]
3316 label = "work"
3317 api_key_env = "OPENROUTER_WORK_API_KEY"
3318
3319 [[openrouter.accounts]]
3320 label = "personal"
3321 api_key = "personal-inline"
3322 "#,
3323 );
3324 let _g = env_guard();
3325 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3326 let config = Config::load_from(f.path()).unwrap();
3327 assert!(!config.openrouter.show_default_account);
3328 assert_eq!(config.openrouter.accounts.len(), 2);
3329 assert_eq!(
3330 config.openrouter.resolve_api_key(None).unwrap(),
3331 "default-inline"
3332 );
3333 assert_eq!(
3334 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
3335 "personal-inline"
3336 );
3337 }
3338
3339 #[test]
3340 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3341 for source in [
3342 r#"
3343 [[openrouter.accounts]]
3344 label = "work"
3345 api_key = "one"
3346 [[openrouter.accounts]]
3347 label = "work"
3348 api_key = "two"
3349 "#,
3350 r#"
3351 [[openrouter.accounts]]
3352 label = "../work"
3353 api_key = "one"
3354 "#,
3355 r#"
3356 [[openrouter.accounts]]
3357 label = "work"
3358 "#,
3359 ] {
3360 let f = write_toml(source);
3361 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3362 }
3363 }
3364
3365 #[test]
3366 fn openrouter_unknown_account_never_falls_back_to_default_key() {
3367 let mut config = OpenRouterConfig {
3368 api_key: Some("default-secret".into()),
3369 ..OpenRouterConfig::default()
3370 };
3371 config.accounts.push(OpenRouterAccount {
3372 label: "work".into(),
3373 api_key_env: None,
3374 api_key: Some("work-secret".into()),
3375 });
3376 let message = config
3377 .resolve_api_key(Some("missing"))
3378 .unwrap_err()
3379 .to_string();
3380 assert!(message.contains("missing") && message.contains("work"));
3381 assert!(!message.contains("default-secret"));
3382 assert!(!message.contains("work-secret"));
3383 }
3384
3385 #[test]
3386 fn openrouter_account_key_errors_do_not_echo_configured_values() {
3387 let config = OpenRouterConfig {
3388 accounts: vec![OpenRouterAccount {
3389 label: "work".into(),
3390 api_key_env: Some("sk_pasted_secret".into()),
3391 api_key: None,
3392 }],
3393 ..OpenRouterConfig::default()
3394 };
3395 let _g = env_guard();
3396 unsafe { std::env::remove_var("sk_pasted_secret") };
3397 let message = config
3398 .resolve_api_key(Some("work"))
3399 .unwrap_err()
3400 .to_string();
3401 assert!(message.contains("[[openrouter.accounts]]"));
3402 assert!(!message.contains("sk_pasted_secret"));
3403 }
3404
3405 #[test]
3406 fn enabled_vendors_preserves_canonical_order() {
3407 let c = Config::default();
3410 assert_eq!(
3411 c.enabled_vendors(),
3412 vec![
3413 VendorId::Anthropic,
3414 VendorId::Openai,
3415 VendorId::Zai,
3416 VendorId::Openrouter,
3417 VendorId::CommandCode,
3418 ]
3419 );
3420 }
3421
3422 #[test]
3423 fn deepseek_appears_when_enabled() {
3424 let f = write_toml(
3425 r#"
3426 [deepseek]
3427 enabled = true
3428 api_key = "sk-test"
3429 "#,
3430 );
3431 let c = Config::load_from(f.path()).unwrap();
3432 assert!(c.is_enabled(VendorId::Deepseek));
3433 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3434 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3435 }
3436
3437 #[test]
3438 fn tilde_paths_are_expanded_on_load() {
3439 let f = write_toml(
3443 r#"
3444 [context]
3445 projects_path = "~/.claude/projects"
3446
3447 [anthropic]
3448 credentials_path = "~/.claude/.credentials.json"
3449
3450 [[anthropic.accounts]]
3451 label = "work"
3452 credentials_path = "~/work.json"
3453 "#,
3454 );
3455 let c = Config::load_from(f.path()).unwrap();
3456 let home = crate::cache::home_dir().unwrap();
3457
3458 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3459 let got = c.anthropic.credentials_path.unwrap();
3460 assert_eq!(got, home.join(".claude/.credentials.json"));
3461 assert!(!got.to_string_lossy().contains('~'));
3462 assert_eq!(
3463 c.anthropic.accounts[0].credentials_path,
3464 home.join("work.json")
3465 );
3466 }
3467
3468 #[test]
3469 fn absolute_and_relative_paths_are_left_alone() {
3470 let f = write_toml(
3471 r#"
3472 [anthropic]
3473 credentials_path = "/etc/creds.json"
3474 "#,
3475 );
3476 let c = Config::load_from(f.path()).unwrap();
3477 assert_eq!(
3478 c.anthropic.credentials_path.unwrap(),
3479 std::path::Path::new("/etc/creds.json")
3480 );
3481
3482 let f2 = write_toml(
3484 r#"
3485 [anthropic]
3486 credentials_path = "~someone/creds.json"
3487 "#,
3488 );
3489 let c2 = Config::load_from(f2.path()).unwrap();
3490 assert_eq!(
3491 c2.anthropic.credentials_path.unwrap(),
3492 std::path::Path::new("~someone/creds.json")
3493 );
3494 }
3495
3496 #[test]
3497 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3498 let _g = path_override_guard();
3499 let p = resolved_path().expect("a config path must resolve");
3502 assert!(p.ends_with("config.toml"));
3503 let canonical = default_path().unwrap();
3504 let legacy = legacy_xdg_path().unwrap();
3505 assert!(
3506 p == canonical || p == legacy,
3507 "resolved to an unexpected location: {}",
3508 p.display()
3509 );
3510 }
3511
3512 #[test]
3513 fn misspelled_section_is_rejected_not_ignored() {
3514 let f = write_toml(
3517 r#"
3518 [openrouer]
3519 enabled = true
3520 api_key = "sk-or-v1-typo"
3521 "#,
3522 );
3523 let err = Config::load_from(f.path()).unwrap_err().to_string();
3524 assert!(
3525 err.contains("openrouer"),
3526 "error should name the typo: {err}"
3527 );
3528 }
3529
3530 #[test]
3531 fn invalid_toml_is_an_error_not_silent_defaults() {
3532 let f = write_toml("[zai\nenabled = true\n");
3533 assert!(Config::load_from(f.path()).is_err());
3534 }
3535
3536 #[test]
3537 fn a_missing_file_is_still_just_defaults() {
3538 let dir = tempfile::tempdir().unwrap();
3541 let missing = dir.path().join("nope").join("config.toml");
3542 let c = Config::load_from(&missing).unwrap();
3543 assert!(c.is_enabled(VendorId::Anthropic));
3544 }
3545
3546 #[test]
3547 fn kimi_appears_when_enabled() {
3548 let f = write_toml(
3549 r#"
3550 [kimi]
3551 enabled = true
3552 api_key = "sk-test"
3553 "#,
3554 );
3555 let c = Config::load_from(f.path()).unwrap();
3556 assert!(c.is_enabled(VendorId::Kimi));
3557 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3558 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3559 }
3560
3561 #[test]
3562 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3563 let f = write_toml(
3564 r#"
3565 [deepseek]
3566 enabled = true
3567 api_key = "sk-ds"
3568
3569 [kimi]
3570 enabled = true
3571 api_key = "sk-kimi"
3572 "#,
3573 );
3574 let c = Config::load_from(f.path()).unwrap();
3575 assert_eq!(
3576 c.enabled_vendors(),
3577 vec![
3578 VendorId::Anthropic,
3579 VendorId::Openai,
3580 VendorId::Zai,
3581 VendorId::Openrouter,
3582 VendorId::Deepseek,
3583 VendorId::Kimi,
3584 VendorId::CommandCode,
3585 ]
3586 );
3587 }
3588
3589 #[test]
3590 fn parses_anthropic_accounts_and_looks_them_up() {
3591 let f = write_toml(
3592 r#"
3593 [anthropic]
3594 enabled = true
3595
3596 [[anthropic.accounts]]
3597 label = "personal"
3598 credentials_path = "/creds/personal.json"
3599
3600 [[anthropic.accounts]]
3601 label = "work"
3602 credentials_path = "/creds/work.json"
3603 "#,
3604 );
3605 let c = Config::load_from(f.path()).unwrap();
3606 assert_eq!(c.anthropic.accounts.len(), 2);
3607 let work = c.anthropic.account("work").unwrap();
3608 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3609 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3611 assert!(err.contains("missing") && err.contains("work"), "{err}");
3612 }
3613
3614 #[test]
3615 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3616 let f = write_toml(
3617 r#"
3618 [[anthropic.accounts]]
3619 label = "work"
3620 credentials_path = "/creds/work-one.json"
3621
3622 [[anthropic.accounts]]
3623 label = "work"
3624 credentials_path = "/creds/work-two.json"
3625 "#,
3626 );
3627 let err = Config::load_from(f.path()).unwrap_err().to_string();
3628 assert!(
3629 err.contains("duplicate anthropic account label \"work\""),
3630 "{err}"
3631 );
3632 }
3633
3634 #[test]
3635 fn account_label_rejects_path_like_names() {
3636 let cfg = AnthropicConfig::default();
3637 for bad in [
3638 "",
3639 ".",
3640 "..",
3641 "a/b",
3642 r"a\b",
3643 "C:work",
3644 "line\nbreak",
3645 "tab\tname",
3646 "usage.json",
3647 ".stale",
3648 ".last_error",
3649 ".fetch.lock",
3650 ] {
3651 let err = cfg.account(bad).unwrap_err();
3652 assert!(
3653 format!("{err:?}").contains("invalid anthropic account label"),
3654 "{bad:?} should be rejected as a label"
3655 );
3656 }
3657 }
3658
3659 #[test]
3660 fn anthropic_accounts_default_to_empty() {
3661 assert!(Config::default().anthropic.accounts.is_empty());
3664 assert!(Config::default().anthropic.accounts_dir.is_none());
3665 }
3666
3667 fn seed_account_dir(root: &std::path::Path, label: &str) {
3673 let dir = root.join(label);
3674 std::fs::create_dir_all(&dir).unwrap();
3675 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3676 }
3677
3678 #[test]
3679 fn discovers_account_dirs_in_claude_config_dir_layout() {
3680 let td = tempfile::tempdir().unwrap();
3681 seed_account_dir(td.path(), "work");
3682 seed_account_dir(td.path(), "personal");
3683 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3686 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3688
3689 let cfg = AnthropicConfig {
3690 accounts_dir: Some(td.path().to_path_buf()),
3691 ..Default::default()
3692 };
3693 let all = cfg.all_accounts();
3694 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3695 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3696 assert_eq!(
3697 all[2].credentials_path,
3698 td.path().join("work").join(".credentials.json")
3699 );
3700 }
3701
3702 #[test]
3703 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3704 let td = tempfile::tempdir().unwrap();
3705 seed_account_dir(td.path(), "work");
3706 let cfg = AnthropicConfig {
3707 accounts: vec![AnthropicAccount {
3708 label: "work".into(),
3709 credentials_path: "/explicit/work.json".into(),
3710 }],
3711 accounts_dir: Some(td.path().to_path_buf()),
3712 ..Default::default()
3713 };
3714 let all = cfg.all_accounts();
3715 assert_eq!(all.len(), 1, "no duplicate label");
3716 assert_eq!(
3717 all[0].credentials_path,
3718 std::path::Path::new("/explicit/work.json"),
3719 "explicit entry wins"
3720 );
3721 seed_account_dir(td.path(), "other");
3723 assert_eq!(cfg.account("other").unwrap().label, "other");
3724 }
3725
3726 #[test]
3727 fn missing_accounts_dir_is_silently_empty_not_an_error() {
3728 let cfg = AnthropicConfig {
3729 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3730 ..Default::default()
3731 };
3732 assert!(cfg.all_accounts().is_empty());
3733 }
3734
3735 #[test]
3736 fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3737 let f = write_toml(
3738 r#"
3739 [[openai.accounts]]
3740 label = "work"
3741 codex_auth_path = "~/.codex-work/auth.json"
3742 "#,
3743 );
3744 let c = Config::load_from(f.path()).unwrap();
3745 let home = crate::cache::home_dir().unwrap();
3746 assert_eq!(
3747 c.openai.accounts[0].codex_auth_path,
3748 home.join(".codex-work/auth.json")
3749 );
3750 }
3751
3752 #[test]
3753 fn accounts_dir_is_tilde_expanded_on_load() {
3754 let f = write_toml(
3755 r#"
3756 [anthropic]
3757 accounts_dir = "~/.config/ai-usagebar/accounts"
3758 "#,
3759 );
3760 let c = Config::load_from(f.path()).unwrap();
3761 let home = crate::cache::home_dir().unwrap();
3762 assert_eq!(
3763 c.anthropic.accounts_dir,
3764 Some(home.join(".config/ai-usagebar/accounts"))
3765 );
3766 }
3767
3768 #[test]
3769 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3770 let f = write_toml(
3771 r#"
3772 [anthropic]
3773 desktop_profiles_dir = "~/.claude-acc/profiles"
3774 "#,
3775 );
3776 let c = Config::load_from(f.path()).unwrap();
3777 let home = crate::cache::home_dir().unwrap();
3778 assert_eq!(
3779 c.anthropic.desktop_profiles_dir,
3780 Some(home.join(".claude-acc/profiles"))
3781 );
3782 }
3783
3784 #[test]
3785 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3786 let cfg = AnthropicConfig {
3787 accounts: vec![
3788 AnthropicAccount {
3789 label: "work".into(),
3790 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3791 },
3792 AnthropicAccount {
3793 label: "personal".into(),
3794 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3795 },
3796 ],
3797 ..Default::default()
3798 };
3799
3800 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
3801 assert!(
3802 matches!(&idle, CredsTarget::Named { config_dir, .. }
3803 if config_dir == std::path::Path::new("/tmp/accounts/work")),
3804 "{idle:?}"
3805 );
3806
3807 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
3809 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
3810
3811 assert_eq!(idle_cache.dir(), live_cache.dir());
3814 }
3815
3816 #[test]
3817 fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
3818 let cfg = AnthropicConfig {
3823 accounts: vec![AnthropicAccount {
3824 label: "personal".into(),
3825 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3826 }],
3827 ..Default::default()
3828 };
3829
3830 let (present, _) = cfg
3831 .account_target_probing("personal", Some("personal"), |_| true)
3832 .unwrap();
3833 assert!(
3834 matches!(&present, CredsTarget::Named { path, .. }
3835 if path == Path::new("/tmp/accounts/personal/.credentials.json")),
3836 "{present:?}"
3837 );
3838
3839 let (moved, _) = cfg
3841 .account_target_probing("personal", Some("personal"), |_| false)
3842 .unwrap();
3843 assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
3844 }
3845
3846 #[test]
3847 fn the_live_cli_accounts_own_file_is_probed_on_disk() {
3848 let creds = NamedTempFile::new().unwrap();
3852 let cfg = AnthropicConfig {
3853 accounts: vec![AnthropicAccount {
3854 label: "personal".into(),
3855 credentials_path: creds.path().to_path_buf(),
3856 }],
3857 ..Default::default()
3858 };
3859 let (target, _) = cfg
3860 .account_target_with("personal", Some("personal"))
3861 .unwrap();
3862 assert!(
3863 matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
3864 "read {target:?} instead of the account's own file"
3865 );
3866 }
3867
3868 #[test]
3869 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
3870 let cfg = AnthropicConfig {
3871 accounts: vec![AnthropicAccount {
3872 label: "work".into(),
3873 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3874 }],
3875 ..Default::default()
3876 };
3877 let (target, _) = cfg.account_target_with("work", None).unwrap();
3878 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
3879 }
3880
3881 fn config_example() -> PathBuf {
3885 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
3886 }
3887
3888 #[test]
3889 fn shipped_example_parses_as_a_real_config() {
3890 let c = Config::load_from(&config_example()).unwrap();
3895 assert!(!c.context.enabled);
3896 assert!(c.is_enabled(VendorId::Anthropic));
3897 assert!(c.is_enabled(VendorId::Openai));
3898 assert!(!c.is_enabled(VendorId::AnthropicApi));
3899 assert!(!c.is_enabled(VendorId::Deepseek));
3900 assert!(!c.is_enabled(VendorId::Kimi));
3901 assert!(!c.is_enabled(VendorId::Kilo));
3902 assert!(!c.is_enabled(VendorId::Novita));
3903 assert!(!c.is_enabled(VendorId::Moonshot));
3904 assert!(!c.is_enabled(VendorId::Grok));
3905 assert!(!c.is_enabled(VendorId::Cursor));
3906 assert!(!c.is_enabled(VendorId::Minimax));
3907 }
3908
3909 #[test]
3910 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
3911 let text = std::fs::read_to_string(config_example()).unwrap();
3916 let live: Vec<&str> = text
3917 .lines()
3918 .map(str::trim)
3919 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
3920 .collect();
3921 assert!(
3922 live.is_empty(),
3923 "admin_key_env must stay commented out while it is inert: {live:?}"
3924 );
3925 assert!(
3928 text.contains("admin_key_env") && text.contains("RESERVED"),
3929 "the example should keep describing admin_key_env as reserved"
3930 );
3931 }
3932
3933 #[test]
3934 fn admin_key_env_is_accepted_but_changes_nothing() {
3935 let f = write_toml(
3939 r#"
3940 [openai]
3941 admin_key_env = "SOME_ADMIN_KEY"
3942 "#,
3943 );
3944 let c = Config::load_from(f.path()).unwrap();
3945 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
3946 let default = OpenAiConfig::default();
3948 assert_eq!(c.openai.enabled, default.enabled);
3949 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
3950 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
3951 }
3952
3953 #[test]
3954 fn config_example_documents_every_vendor_without_secrets() {
3955 let raw = std::fs::read_to_string(config_example()).unwrap();
3956 let cfg = Config::load_from(&config_example()).unwrap();
3957 for id in VendorId::all() {
3960 let section = id.slug();
3961 assert!(
3962 raw.contains(&format!("[{section}]")),
3963 "config.example.toml has no [{section}] section"
3964 );
3965 }
3966
3967 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
3970 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
3971 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
3972 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
3973 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
3974 assert!(!cfg.supergrok.enabled);
3975 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
3976 assert_eq!(
3977 cfg.supergrok
3978 .grok_binary
3979 .file_name()
3980 .and_then(|p| p.to_str()),
3981 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
3982 );
3983 assert!(cfg.supergrok.auth_path.is_none());
3984 assert!(cfg.supergrok.config_path.is_none());
3985 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
3986 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
3987 }
3988
3989 #[test]
3990 fn supergrok_binary_must_not_be_empty() {
3991 let file = write_toml(
3992 r#"
3993 [supergrok]
3994 enabled = true
3995 grok_binary = ""
3996 "#,
3997 );
3998 let error = Config::load_from(file.path()).unwrap_err().to_string();
3999 assert!(error.contains("grok_binary must not be empty"));
4000 }
4001
4002 #[test]
4003 fn supergrok_paths_are_tilde_expanded() {
4004 let file = write_toml(
4005 r#"
4006 [supergrok]
4007 grok_binary = "~/bin/grok"
4008 auth_path = "~/.grok/auth.json"
4009 config_path = "~/.grok/config.toml"
4010 "#,
4011 );
4012 let config = Config::load_from(file.path()).unwrap();
4013 let home = crate::cache::home_dir().unwrap();
4014 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
4015 assert_eq!(
4016 config.supergrok.auth_path,
4017 Some(home.join(".grok/auth.json"))
4018 );
4019 assert_eq!(
4020 config.supergrok.config_path,
4021 Some(home.join(".grok/config.toml"))
4022 );
4023 }
4024
4025 #[test]
4026 fn kiro_db_path_is_tilde_expanded() {
4027 let f = write_toml(
4028 r#"
4029 [kiro]
4030 db_path = "~/kiro-data.sqlite3"
4031 "#,
4032 );
4033 let c = Config::load_from(f.path()).unwrap();
4034 let home = crate::cache::home_dir().unwrap();
4035 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
4036 }
4037
4038 #[test]
4039 fn kiro_appears_when_enabled() {
4040 let f = write_toml(
4041 r#"
4042 [kiro]
4043 enabled = true
4044 "#,
4045 );
4046 let c = Config::load_from(f.path()).unwrap();
4047 assert!(c.is_enabled(VendorId::Kiro));
4048 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
4049 }
4050
4051 #[test]
4052 fn cursor_db_path_is_tilde_expanded() {
4053 let f = write_toml(
4054 r#"
4055 [cursor]
4056 db_path = "~/cursor-state.vscdb"
4057 "#,
4058 );
4059 let c = Config::load_from(f.path()).unwrap();
4060 let home = crate::cache::home_dir().unwrap();
4061 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
4062 }
4063
4064 #[test]
4065 fn cursor_agent_auth_path_is_tilde_expanded() {
4066 let f = write_toml(
4067 r#"
4068 [cursor]
4069 agent_auth_path = "~/cursor-agent-auth.json"
4070 "#,
4071 );
4072 let c = Config::load_from(f.path()).unwrap();
4073 let home = crate::cache::home_dir().unwrap();
4074 assert_eq!(
4075 c.cursor.agent_auth_path,
4076 Some(home.join("cursor-agent-auth.json"))
4077 );
4078 }
4079
4080 #[test]
4081 fn cursor_appears_when_enabled() {
4082 let f = write_toml(
4083 r#"
4084 [cursor]
4085 enabled = true
4086 "#,
4087 );
4088 let c = Config::load_from(f.path()).unwrap();
4089 assert!(c.is_enabled(VendorId::Cursor));
4090 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
4091 }
4092
4093 #[test]
4094 fn add_account_appends_and_preserves_existing() {
4095 let mut doc: toml_edit::DocumentMut = r#"
4096# keep me
4097[anthropic]
4098enabled = true
4099
4100[[anthropic.accounts]]
4101label = "personal"
4102credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
4103"#
4104 .parse()
4105 .unwrap();
4106 add_anthropic_account_to_doc(
4107 &mut doc,
4108 "work",
4109 "~/.config/ai-usagebar/accounts/work/.credentials.json",
4110 )
4111 .unwrap();
4112 let rendered = doc.to_string();
4113 assert!(rendered.contains("# keep me"), "comment must survive");
4114 let f = write_toml(&rendered);
4116 let c = Config::load_from(f.path()).unwrap();
4117 let labels: Vec<&str> = c
4118 .anthropic
4119 .accounts
4120 .iter()
4121 .map(|a| a.label.as_str())
4122 .collect();
4123 assert_eq!(labels, vec!["personal", "work"]);
4124 }
4125
4126 #[test]
4127 fn add_account_to_empty_doc_is_loadable() {
4128 let mut doc = toml_edit::DocumentMut::new();
4129 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
4130 let f = write_toml(&doc.to_string());
4131 let c = Config::load_from(f.path()).unwrap();
4132 assert_eq!(c.anthropic.accounts.len(), 1);
4133 assert_eq!(c.anthropic.accounts[0].label, "solo");
4134 }
4135
4136 #[test]
4137 fn add_account_rejects_duplicate_label() {
4138 let mut doc: toml_edit::DocumentMut = r#"
4139[[anthropic.accounts]]
4140label = "work"
4141credentials_path = "~/w/.credentials.json"
4142"#
4143 .parse()
4144 .unwrap();
4145 assert!(
4146 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
4147 "a duplicate label must be rejected, not appended"
4148 );
4149 }
4150
4151 #[test]
4152 fn add_account_rejects_bad_label() {
4153 let mut doc = toml_edit::DocumentMut::new();
4154 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
4155 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
4156 }
4157
4158 #[test]
4159 fn tildify_collapses_home_only() {
4160 let home = Path::new("/Users/me");
4161 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
4162 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
4163 }
4164
4165 #[test]
4166 fn default_account_credentials_path_nests_under_config_dir() {
4167 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
4168 assert_eq!(
4169 default_account_credentials_path(cfg, "work"),
4170 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
4171 );
4172 }
4173 const CUSTOM_BLOCK: &str = r#"
4176[[custom]]
4177id = "mytool"
4178name = "My Tool"
4179short_name = "myt"
4180enabled = true
4181url = "https://api.example.test/v1/usage"
4182api_key_env = "MYTOOL_API_KEY"
4183auth_header = "Authorization"
4184auth_scheme = "Bearer"
4185plan = "Pro"
4186cache_ttl_secs = 120
4187[custom.headers]
4188X-Org = "org_1"
4189[[custom.metrics]]
4190label = "Requests"
4191used = "/requests/used"
4192limit = "/requests/limit"
4193resets_at = "/requests/reset"
4194window_secs = 3600
4195[[custom.texts]]
4196label = "Tier"
4197value = "/tier"
4198"#;
4199
4200 fn custom_with(from: &str, to: &str) -> String {
4201 assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
4202 CUSTOM_BLOCK.replace(from, to)
4203 }
4204
4205 fn custom_error(toml: &str) -> String {
4206 Config::load_from(write_toml(toml).path())
4207 .unwrap_err()
4208 .to_string()
4209 }
4210
4211 fn assert_custom_rejected(toml: &str, needle: &str) {
4212 let msg = custom_error(toml);
4213 assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
4214 assert!(
4215 msg.contains("[[custom]]"),
4216 "the error must locate the section: {msg}"
4217 );
4218 }
4219
4220 #[test]
4221 fn custom_block_parses_every_field() {
4222 let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
4223 assert_eq!(config.custom.len(), 1);
4224 let c = &config.custom[0];
4225 assert_eq!(c.id, "mytool");
4226 assert_eq!(c.name, "My Tool");
4227 assert_eq!(c.short_name, "myt");
4228 assert_eq!(
4229 c.brand, None,
4230 "a custom provider has no mark unless it asks"
4231 );
4232 assert!(c.enabled);
4233 assert_eq!(c.url, "https://api.example.test/v1/usage");
4234 assert!(!c.allow_http);
4235 assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
4236 assert_eq!(c.api_key, None);
4237 assert_eq!(c.auth_header, "Authorization");
4238 assert_eq!(c.auth_scheme, "Bearer");
4239 assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
4240 assert_eq!(c.plan.as_deref(), Some("Pro"));
4241 assert_eq!(c.plan_path, None);
4242 assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
4243 assert_eq!(c.metrics.len(), 1);
4244 assert_eq!(c.metrics[0].label, "Requests");
4245 assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
4246 assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
4247 assert_eq!(c.metrics[0].percent, None);
4248 assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
4249 assert_eq!(c.metrics[0].window_secs, Some(3600));
4250 assert_eq!(c.texts.len(), 1);
4251 assert_eq!(c.texts[0].label, "Tier");
4252 assert_eq!(c.texts[0].value, "/tier");
4253 assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
4254 }
4255
4256 #[test]
4257 fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
4258 let config: Config = toml::from_str(
4259 r#"
4260 [[custom]]
4261 id = "bare"
4262 short_name = "bre"
4263 url = "https://example.test/u"
4264 [[custom.metrics]]
4265 label = "Q"
4266 percent = "/pct"
4267 "#,
4268 )
4269 .unwrap();
4270 let c = &config.custom[0];
4271 assert_eq!(c.name, "bare", "name must default to id on a plain parse");
4272 assert!(!c.enabled);
4273 assert!(!c.allow_http);
4274 assert_eq!(c.api_key_env, "");
4275 assert_eq!(c.auth_header, "Authorization");
4276 assert_eq!(c.auth_scheme, "Bearer");
4277 assert_eq!(c.cache_ttl_secs, 60);
4278 assert!(config.validate().is_ok());
4279 assert!(Config::default().custom.is_empty());
4280 }
4281
4282 #[test]
4283 fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
4284 let config = Config::load_from(
4285 write_toml(&custom_with(
4286 r#"short_name = "myt""#,
4287 "short_name = \"myt\"\nbrand = \"opencode-go\"",
4288 ))
4289 .path(),
4290 )
4291 .unwrap();
4292 assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4293
4294 for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4298 assert_custom_rejected(
4299 &custom_with(
4300 r#"short_name = "myt""#,
4301 &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4302 ),
4303 "must name a built-in vendor",
4304 );
4305 }
4306 }
4307
4308 #[test]
4309 fn custom_rejects_a_malformed_id() {
4310 let long = "a".repeat(33);
4311 for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4312 let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4313 assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4314 assert!(msg.contains("must match"), "{id:?}: {msg}");
4315 }
4316 }
4317
4318 #[test]
4319 fn custom_rejects_a_builtin_slug_as_id() {
4320 assert_custom_rejected(
4321 &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4322 "is a built-in vendor",
4323 );
4324 assert_custom_rejected(
4325 &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4326 "is a built-in vendor",
4327 );
4328 }
4329
4330 #[test]
4331 fn custom_rejects_duplicate_ids() {
4332 let twice = format!(
4333 "{}{}",
4334 CUSTOM_BLOCK,
4335 custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4336 );
4337 assert_custom_rejected(&twice, "duplicate id");
4338 }
4339
4340 #[test]
4341 fn custom_rejects_a_name_over_48_chars() {
4342 let long = "n".repeat(49);
4343 assert_custom_rejected(
4344 &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4345 "name must be 1 to 48 characters",
4346 );
4347 }
4348
4349 #[test]
4350 fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4351 for short in ["my", "myto", "MYT", "m1t"] {
4352 assert_custom_rejected(
4353 &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4354 "exactly 3 lowercase ASCII letters",
4355 );
4356 }
4357 }
4358
4359 #[test]
4360 fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4361 assert_custom_rejected(
4362 &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4363 "already used by a built-in vendor",
4364 );
4365 let twice = format!(
4366 "{}{}",
4367 CUSTOM_BLOCK,
4368 custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4369 );
4370 assert_custom_rejected(&twice, "already used by a built-in vendor");
4371 }
4372
4373 #[test]
4374 fn custom_rejects_http_unless_allowed() {
4375 let plain = custom_with(
4376 r#"url = "https://api.example.test/v1/usage""#,
4377 r#"url = "http://localhost:8080/usage""#,
4378 );
4379 assert_custom_rejected(&plain, "url must use https://");
4380 let allowed = plain.replace(
4381 r#"url = "http://localhost:8080/usage""#,
4382 "url = \"http://localhost:8080/usage\"\nallow_http = true",
4383 );
4384 assert!(
4385 Config::load_from(write_toml(&allowed).path()).is_ok(),
4386 "allow_http must permit http://"
4387 );
4388 }
4389
4390 #[test]
4391 fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4392 assert_custom_rejected(
4393 &custom_with(
4394 r#"url = "https://api.example.test/v1/usage""#,
4395 r#"url = "https://user:pw@api.example.test/v1/usage""#,
4396 ),
4397 "must not carry credentials",
4398 );
4399 assert_custom_rejected(
4400 &custom_with(
4401 r#"url = "https://api.example.test/v1/usage""#,
4402 r#"url = "not a url""#,
4403 ),
4404 "is not a valid URL",
4405 );
4406 assert_custom_rejected(
4407 &custom_with(
4408 r#"url = "https://api.example.test/v1/usage""#,
4409 r#"url = "ftp://api.example.test/v1/usage""#,
4410 ),
4411 "is not http or https",
4412 );
4413 }
4414
4415 #[test]
4416 fn custom_rejects_an_invalid_api_key_env() {
4417 assert_custom_rejected(
4418 &custom_with(
4419 r#"api_key_env = "MYTOOL_API_KEY""#,
4420 r#"api_key_env = "1BAD-NAME""#,
4421 ),
4422 "is not a valid environment variable name",
4423 );
4424 let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4425 assert!(
4426 Config::load_from(write_toml(&none).path()).is_ok(),
4427 "an empty api_key_env means inline-only and is valid"
4428 );
4429 }
4430
4431 #[test]
4432 fn custom_rejects_an_invalid_auth_header_name() {
4433 assert_custom_rejected(
4434 &custom_with(
4435 r#"auth_header = "Authorization""#,
4436 r#"auth_header = "X Api Key""#,
4437 ),
4438 "auth_header \"X Api Key\" is not a valid HTTP header name",
4439 );
4440 }
4441
4442 #[test]
4443 fn custom_rejects_a_control_char_in_auth_scheme() {
4444 assert_custom_rejected(
4445 &custom_with(
4446 r#"auth_scheme = "Bearer""#,
4447 "auth_scheme = \"Bearer\\u0007\"",
4448 ),
4449 "auth_scheme contains characters that are not valid",
4450 );
4451 let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4452 assert!(
4453 Config::load_from(write_toml(&bare).path()).is_ok(),
4454 "an empty scheme (bare key) is valid"
4455 );
4456 }
4457
4458 #[test]
4459 fn custom_rejects_a_bad_extra_header() {
4460 assert_custom_rejected(
4461 &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4462 "headers must not repeat auth_header",
4463 );
4464 assert_custom_rejected(
4465 &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4466 "is not a valid HTTP header name",
4467 );
4468 assert_custom_rejected(
4469 &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4470 "has a value that is not valid in an HTTP header",
4471 );
4472 }
4473
4474 #[test]
4475 fn custom_rejects_cache_ttl_outside_10_to_3600() {
4476 for ttl in ["9", "3601"] {
4477 assert_custom_rejected(
4478 &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4479 "cache_ttl_secs must be between 10 and 3600",
4480 );
4481 }
4482 }
4483
4484 #[test]
4485 fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4486 let toml = r#"
4487[[custom]]
4488id = "empty"
4489short_name = "emp"
4490url = "https://example.test/u"
4491"#;
4492 assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4493 }
4494
4495 #[test]
4496 fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4497 assert_custom_rejected(
4498 &custom_with(
4499 r#"limit = "/requests/limit""#,
4500 "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4501 ),
4502 "must set `percent`, or both `used` and `limit`",
4503 );
4504 assert_custom_rejected(
4505 &custom_with("limit = \"/requests/limit\"\n", ""),
4506 "must set `percent`, or both `used` and `limit`",
4507 );
4508 }
4509
4510 #[test]
4511 fn custom_rejects_a_pointer_without_a_leading_slash() {
4512 assert_custom_rejected(
4513 &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4514 "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4515 );
4516 assert_custom_rejected(
4517 &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4518 "value \"tier\" must be an RFC 6901 JSON Pointer",
4519 );
4520 assert_custom_rejected(
4521 &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4522 "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4523 );
4524 assert_custom_rejected(
4525 &custom_with(
4526 r#"resets_at = "/requests/reset""#,
4527 "resets_at = \"/re\\u001bset\"",
4528 ),
4529 "resets_at",
4530 );
4531 }
4532
4533 #[test]
4534 fn custom_rejects_a_label_outside_1_to_64_chars() {
4535 let long = "l".repeat(65);
4536 assert_custom_rejected(
4537 &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4538 "metric label",
4539 );
4540 assert_custom_rejected(
4541 &custom_with(r#"label = "Tier""#, r#"label = """#),
4542 "text label \"\" must be 1 to 64 characters",
4543 );
4544 }
4545
4546 #[test]
4547 fn custom_rejects_window_secs_under_60() {
4548 assert_custom_rejected(
4549 &custom_with("window_secs = 3600", "window_secs = 59"),
4550 "window_secs must be at least 60",
4551 );
4552 }
4553
4554 #[test]
4555 fn custom_rejects_duplicate_metric_and_text_labels() {
4556 let metric_twice = custom_with(
4557 "window_secs = 3600\n",
4558 "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4559 );
4560 assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4561 let text_twice =
4562 format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4563 assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4564 }
4565
4566 #[test]
4567 fn enabled_custom_and_custom_by_id_select_entries() {
4568 let two = format!(
4569 "{}{}",
4570 CUSTOM_BLOCK,
4571 custom_with(r#"id = "mytool""#, r#"id = "off""#)
4572 .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4573 .replace("enabled = true", "enabled = false")
4574 );
4575 let config = Config::load_from(write_toml(&two).path()).unwrap();
4576 let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4577 assert_eq!(enabled, ["mytool"]);
4578 assert_eq!(
4579 config.custom_by_id("off").map(|c| c.name.as_str()),
4580 Some("My Tool")
4581 );
4582 assert!(config.custom_by_id("nope").is_none());
4583 }
4584
4585 #[cfg(unix)]
4586 #[test]
4587 fn has_inline_secrets_sees_a_custom_inline_key() {
4588 let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4589 assert!(!without.has_inline_secrets());
4590 let with: Config = toml::from_str(&custom_with(
4591 r#"api_key_env = "MYTOOL_API_KEY""#,
4592 "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4593 ))
4594 .unwrap();
4595 assert!(with.has_inline_secrets());
4596 }
4597
4598 #[test]
4599 fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4600 let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4601 let mut spec = CustomProviderConfig {
4602 id: "mytool".into(),
4603 api_key_env: var.into(),
4604 api_key: Some("sk-inline-secret".into()),
4605 ..CustomProviderConfig::default()
4606 };
4607 unsafe { std::env::set_var(var, "sk-env-secret") };
4608 let from_env = spec.resolve_api_key();
4609 unsafe { std::env::remove_var(var) };
4610 assert_eq!(from_env.unwrap(), "sk-env-secret");
4611
4612 assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4613
4614 spec.api_key = Some(String::new());
4615 let err = spec.resolve_api_key().unwrap_err();
4616 assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4617 let msg = err.to_string();
4618 assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4619 assert!(msg.contains(var), "{msg}");
4620 assert!(!msg.contains("secret"), "{msg}");
4621
4622 spec.api_key_env = String::new();
4623 let msg = spec.resolve_api_key().unwrap_err().to_string();
4624 assert!(msg.contains("set `api_key`"), "{msg}");
4625 }
4626
4627 #[test]
4628 fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4629 let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4630 assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4631 let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4632 Config::load_from(file.path()).unwrap();
4633 assert!(
4634 crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4635 "a custom provider's env var must be scrubbed from subprocesses"
4636 );
4637 }
4638
4639 #[test]
4644 fn every_config_section_parses_to_its_vendors_enabled_switch() {
4645 for vendor in VendorId::all() {
4646 let text = format!(
4647 "[{}]
4648enabled = true
4649",
4650 vendor.config_section()
4651 );
4652 let config: Config = toml::from_str(&text)
4653 .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4654 assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4655 let others = VendorId::all()
4656 .iter()
4657 .filter(|other| *other != vendor && config.is_enabled(**other))
4658 .count();
4659 assert_eq!(
4660 others,
4661 Config::default().enabled_vendors().len()
4662 - usize::from(Config::default().is_enabled(*vendor)),
4663 "[{}] enabled a different vendor",
4664 vendor.config_section()
4665 );
4666 }
4667 }
4668
4669 #[test]
4670 fn tray_section_parses_and_defaults_to_notify() {
4671 let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4672 let config = Config::load_from(file.path()).unwrap();
4673 assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4674 assert_eq!(config.tray.updates(), UpdateMode::Auto);
4675
4676 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4677 assert_eq!(empty.tray, TrayConfig::default());
4678 assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4679 assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4680 assert_eq!(UpdateMode::parse("weekly"), None);
4681 assert_eq!(UpdateMode::Auto.as_str(), "auto");
4682 }
4683
4684 #[test]
4685 fn tray_section_rejects_a_misspelled_mode() {
4686 let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4687 assert!(Config::load_from(file.path()).is_err());
4688 }
4689
4690 #[test]
4691 fn tray_refresh_minutes_defaults_to_five_and_parses() {
4692 let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4693 assert_eq!(empty.tray.refresh_minutes, None);
4694 assert_eq!(empty.tray.refresh_minutes(), 5);
4695
4696 let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4697 let config = Config::load_from(file.path()).unwrap();
4698 assert_eq!(config.tray.refresh_minutes(), 10);
4699 }
4700
4701 #[test]
4702 fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4703 for minutes in ["3", "0"] {
4704 let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4705 let error = Config::load_from(file.path()).unwrap_err().to_string();
4706 assert!(error.contains("[tray] refresh_minutes"), "{error}");
4707 assert!(error.contains("1, 5 or 10"), "{error}");
4708 }
4709 }
4710
4711 #[test]
4712 fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4713 let dir = tempfile::tempdir().unwrap();
4714 let path = dir.path().join("config.toml");
4715 std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4716
4717 set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4718 let text = std::fs::read_to_string(&path).unwrap();
4719 assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4720 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4721
4722 set_tray_value(&path, "refresh_minutes", None).unwrap();
4723 assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4724 }
4725
4726 #[test]
4727 fn set_tray_value_creates_replaces_and_removes_keys() {
4728 let dir = tempfile::tempdir().unwrap();
4729 let path = dir.path().join("config.toml");
4730 std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4731
4732 set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4733 let text = std::fs::read_to_string(&path).unwrap();
4734 assert!(text.contains("# primary = \"anthropic\""), "{text}");
4735 assert!(
4736 text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4737 "{text}"
4738 );
4739
4740 set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
4741 set_tray_value(&path, "updates", Some("off".into())).unwrap();
4742 let config = Config::load_from(&path).unwrap();
4743 assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
4744 assert_eq!(config.tray.updates(), UpdateMode::Off);
4745
4746 set_tray_value(&path, "shortcut", None).unwrap();
4747 let text = std::fs::read_to_string(&path).unwrap();
4748 assert!(!text.contains("shortcut"), "{text}");
4749 assert!(text.contains("updates = \"off\""), "{text}");
4750
4751 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
4753 set_tray_value(&path, "shortcut", None).unwrap();
4754 assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
4755 }
4756
4757 #[test]
4758 fn set_value_keeps_the_trailing_comment_when_replacing() {
4759 let mut doc: toml_edit::DocumentMut =
4760 "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
4761 set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
4762 assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
4763 }
4764
4765 #[test]
4766 fn enable_vendors_in_creates_a_missing_config() {
4767 let dir = tempfile::TempDir::new().unwrap();
4768 let path = dir.path().join("sub").join("config.toml");
4769
4770 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4771
4772 assert_eq!(
4773 std::fs::read_to_string(&path).unwrap(),
4774 "[grok]
4775enabled = true
4776"
4777 );
4778 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4779 }
4780
4781 #[test]
4782 fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
4783 let dir = tempfile::TempDir::new().unwrap();
4784 let path = dir.path().join("config.toml");
4785 let original = "# my settings
4786[zai]
4787api_key = \"x\" # keep
4788enabled = false
4789";
4790 std::fs::write(&path, original).unwrap();
4791
4792 enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
4793
4794 let text = std::fs::read_to_string(&path).unwrap();
4795 assert!(
4796 text.starts_with(
4797 "# my settings
4798"
4799 ),
4800 "{text}"
4801 );
4802 assert!(
4803 text.contains(
4804 "api_key = \"x\" # keep
4805"
4806 ),
4807 "{text}"
4808 );
4809 assert!(
4810 text.contains(
4811 "[grok]
4812enabled = true
4813"
4814 ),
4815 "{text}"
4816 );
4817 assert!(
4818 text.contains(
4819 "[opencode-go]
4820enabled = true
4821"
4822 ),
4823 "{text}"
4824 );
4825 let config = Config::load_from(&path).unwrap();
4826 assert!(
4827 !config.is_enabled(VendorId::Zai),
4828 "never widens to false, never flips others"
4829 );
4830 assert!(config.is_enabled(VendorId::Grok));
4831 assert!(config.is_enabled(VendorId::OpenCodeGo));
4832 }
4833
4834 #[test]
4835 fn enable_vendors_in_leaves_an_explicit_false_alone() {
4836 let dir = tempfile::TempDir::new().unwrap();
4837 let path = dir.path().join("config.toml");
4838 let original = "[grok]
4839enabled = false # off
4840api_key = \"k\"
4841";
4842 std::fs::write(&path, original).unwrap();
4843
4844 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4848
4849 assert!(written.is_empty(), "{written:?}");
4850 assert_eq!(
4851 std::fs::read_to_string(&path).unwrap(),
4852 original,
4853 "the file must not be rewritten at all"
4854 );
4855 }
4856
4857 #[test]
4858 fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
4859 let dir = tempfile::TempDir::new().unwrap();
4860 let path = dir.path().join("config.toml");
4861 std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
4862
4863 let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4864
4865 assert_eq!(written, vec![VendorId::Grok]);
4866 assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4867 }
4868
4869 #[test]
4870 fn enable_vendors_in_is_textually_idempotent() {
4871 let dir = tempfile::TempDir::new().unwrap();
4872 let path = dir.path().join("config.toml");
4873 let original = "[grok]
4874enabled = true
4875
4876# trailing
4877";
4878 std::fs::write(&path, original).unwrap();
4879 let before = std::fs::metadata(&path).unwrap().modified().unwrap();
4880
4881 enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4882
4883 assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
4884 assert_eq!(
4885 std::fs::metadata(&path).unwrap().modified().unwrap(),
4886 before,
4887 "an unchanged document must not be rewritten"
4888 );
4889 }
4890
4891 #[test]
4892 fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
4893 let dir = tempfile::TempDir::new().unwrap();
4894 let path = dir.path().join("config.toml");
4895
4896 enable_vendors_in(&path, &[]).unwrap();
4897
4898 assert!(!path.exists());
4899 }
4900}