1use std::collections::{BTreeMap, HashSet};
18use std::path::{Path, PathBuf};
19
20#[cfg(unix)]
21use std::os::unix::fs::{MetadataExt, PermissionsExt};
22
23use serde::{Deserialize, Serialize};
24
25use crate::anthropic::creds::CredsTarget;
26use crate::cache::Cache;
27use crate::error::{AppError, Result};
28use crate::vendor::VendorId;
29
30#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default, deny_unknown_fields)]
38pub struct Config {
39 pub ui: UiConfig,
40 pub context: ContextConfig,
41 pub anthropic: AnthropicConfig,
42 pub anthropic_api: AnthropicApiConfig,
43 pub openai: OpenAiConfig,
44 pub zai: ZaiConfig,
45 pub openrouter: OpenRouterConfig,
46 pub deepseek: DeepseekConfig,
47 pub kimi: KimiConfig,
48 pub kilo: KiloConfig,
49 pub novita: NovitaConfig,
50 pub moonshot: MoonshotConfig,
51 pub grok: GrokConfig,
52 pub supergrok: SuperGrokConfig,
53 pub antigravity: AntigravityConfig,
54 pub cursor: CursorConfig,
55 pub minimax: MinimaxConfig,
56 pub kiro: KiroConfig,
57 pub nous: NousConfig,
58 #[serde(rename = "opencode-go")]
59 pub opencode_go: OpenCodeGoConfig,
60}
61
62#[derive(Debug, Clone, Default, Deserialize, Serialize)]
66#[serde(default)]
67pub struct UiConfig {
68 pub primary: Option<VendorId>,
70 pub overview_vendors: Option<Vec<VendorId>>,
74 pub vendor_box: Option<VendorBoxStyle>,
76}
77
78impl UiConfig {
79 pub fn vendor_box(&self) -> VendorBoxStyle {
80 self.vendor_box.unwrap_or_default()
81 }
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
86#[serde(rename_all = "lowercase")]
87pub enum VendorBoxStyle {
88 #[default]
90 Sidebar,
91 Navbar,
93 None,
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
100#[serde(rename_all = "lowercase")]
101pub enum ContextLayout {
102 #[default]
104 Full,
105 Split,
107 Bottom,
109}
110
111impl ContextLayout {
112 pub fn next(self) -> Self {
113 match self {
114 ContextLayout::Full => ContextLayout::Split,
115 ContextLayout::Split => ContextLayout::Bottom,
116 ContextLayout::Bottom => ContextLayout::Full,
117 }
118 }
119
120 pub fn label(self) -> &'static str {
121 match self {
122 ContextLayout::Full => "full",
123 ContextLayout::Split => "split",
124 ContextLayout::Bottom => "bottom",
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default, Deserialize, Serialize)]
134#[serde(default)]
135pub struct ContextConfig {
136 pub enabled: bool,
139 pub projects_path: Option<PathBuf>,
141 pub context_window_tokens: Option<u64>,
144 pub model_context_window_tokens: BTreeMap<String, u64>,
147 pub layout: ContextLayout,
149}
150
151impl ContextConfig {
152 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
153 model
154 .and_then(|model| self.model_context_window_tokens.get(model).copied())
155 .filter(|tokens| *tokens > 0)
156 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
157 }
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
161#[serde(default)]
162pub struct AnthropicConfig {
163 pub enabled: bool,
164 pub credentials_path: Option<PathBuf>,
167 pub accounts: Vec<AnthropicAccount>,
171 pub accounts_dir: Option<PathBuf>,
179 pub show_default_account: bool,
185 pub desktop_profiles_dir: Option<PathBuf>,
191}
192
193impl Default for AnthropicConfig {
194 fn default() -> Self {
195 Self {
196 enabled: true,
197 credentials_path: None,
198 accounts: Vec::new(),
199 accounts_dir: None,
200 show_default_account: true,
201 desktop_profiles_dir: None,
202 }
203 }
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
216pub struct AnthropicAccount {
217 pub label: String,
220 pub credentials_path: PathBuf,
224}
225
226impl AnthropicAccount {
227 pub fn config_dir(&self) -> PathBuf {
232 self.credentials_path
233 .parent()
234 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
235 }
236}
237
238impl AnthropicConfig {
239 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
245 let mut out = self.accounts.clone();
246 if let Some(dir) = &self.accounts_dir {
247 for acct in discover_accounts(dir) {
248 if !out.iter().any(|a| a.label == acct.label) {
249 out.push(acct);
250 }
251 }
252 }
253 out
254 }
255
256 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
261 validate_account_label(label)?;
262 let all = self.all_accounts();
263 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
264 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
265 AppError::Credentials(format!(
266 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
267 known labels: {known:?}"
268 ))
269 })
270 }
271
272 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
283 let active = crate::anthropic::cli_account::home_claude_json()
284 .ok()
285 .and_then(|path| {
286 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
287 });
288 self.account_target_with(label, active.as_deref())
289 }
290
291 pub fn account_target_with(
302 &self,
303 label: &str,
304 cli_active: Option<&str>,
305 ) -> Result<(CredsTarget, Cache)> {
306 let account = self.account(label)?;
307 let cache = Cache::for_vendor_account("anthropic", label)?;
308 if cli_active == Some(label) {
309 return Ok((
310 CredsTarget::Default(crate::anthropic::creds::default_path()?),
311 cache,
312 ));
313 }
314 Ok((
315 CredsTarget::Named {
316 config_dir: account.config_dir(),
317 path: account.credentials_path,
318 },
319 cache,
320 ))
321 }
322}
323
324pub fn validate_account_label(label: &str) -> Result<()> {
330 validate_account_label_for("anthropic", label)
331}
332
333fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
334 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
335 let bad = label.is_empty()
336 || label == "."
337 || label == ".."
338 || label.contains(['/', '\\'])
339 || label.contains(':')
340 || label.chars().any(char::is_control)
341 || RESERVED.contains(&label);
342 if bad {
343 return Err(AppError::Credentials(format!(
344 "invalid {vendor} account label {label:?}: must be a non-empty name \
345 without path separators, drive prefixes, control characters, or reserved cache names"
346 )));
347 }
348 Ok(())
349}
350
351fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
359 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
360 return Vec::new();
361 };
362 let mut found: Vec<AnthropicAccount> = entries
363 .flatten()
364 .filter_map(|entry| {
365 let path = entry.path();
366 if !path.is_dir() {
367 return None;
368 }
369 let label = path.file_name()?.to_str()?.to_string();
370 validate_account_label(&label).ok()?;
371 Some(AnthropicAccount {
372 label,
373 credentials_path: path.join(".credentials.json"),
374 })
375 })
376 .collect();
377 found.sort_by(|a, b| a.label.cmp(&b.label));
378 found
379}
380
381pub fn tildify(path: &Path, home: &Path) -> String {
385 path.strip_prefix(home)
386 .map(|rest| {
387 let rendered = rest.display().to_string();
388 #[cfg(windows)]
391 let rendered = rendered.replace('\\', "/");
392 format!("~/{rendered}")
393 })
394 .unwrap_or_else(|_| path.display().to_string())
395}
396
397pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
402 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
403 base.join("accounts").join(label).join(".credentials.json")
404}
405
406pub fn add_anthropic_account_to_doc(
412 doc: &mut toml_edit::DocumentMut,
413 label: &str,
414 credentials_path: &str,
415) -> Result<()> {
416 use toml_edit::{Item, Table, value};
417
418 validate_account_label(label)?;
419
420 let anthropic = doc
421 .entry("anthropic")
422 .or_insert_with(|| Item::Table(Table::new()));
423 let anthropic = anthropic
424 .as_table_mut()
425 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
426
427 let accounts = anthropic
428 .entry("accounts")
429 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
430 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
431 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
432 })?;
433
434 let exists = accounts
435 .iter()
436 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
437 if exists {
438 return Err(AppError::Credentials(format!(
439 "anthropic account {label:?} already exists in config.toml"
440 )));
441 }
442
443 let mut table = Table::new();
444 table["label"] = value(label);
445 table["credentials_path"] = value(credentials_path);
446 accounts.push(table);
447 Ok(())
448}
449
450#[derive(Debug, Clone, Deserialize, Serialize)]
451#[serde(default)]
452pub struct OpenAiConfig {
453 pub enabled: bool,
454 pub codex_auth_path: Option<PathBuf>,
456 pub admin_key_env: String,
464}
465
466impl Default for OpenAiConfig {
467 fn default() -> Self {
468 Self {
469 enabled: true,
470 codex_auth_path: None,
471 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
472 }
473 }
474}
475
476#[derive(Debug, Clone, Default, Deserialize, Serialize)]
477#[serde(default)]
478pub struct NousConfig {
479 pub enabled: bool,
480}
481
482#[derive(Debug, Clone, Deserialize, Serialize)]
483#[serde(default)]
484pub struct OpenCodeGoConfig {
485 pub enabled: bool,
486 pub api_key_env: String,
487 pub api_key: Option<String>,
488}
489
490impl Default for OpenCodeGoConfig {
491 fn default() -> Self {
492 Self {
493 enabled: false,
494 api_key_env: "OPENCODE_GO_API_KEY".to_string(),
495 api_key: None,
496 }
497 }
498}
499
500#[derive(Debug, Clone, Deserialize, Serialize)]
501#[serde(default)]
502pub struct ZaiConfig {
503 pub enabled: bool,
504 pub api_key_env: String,
506 pub api_key: Option<String>,
509 pub plan_tier: Option<String>,
511}
512
513impl Default for ZaiConfig {
514 fn default() -> Self {
515 Self {
516 enabled: true,
517 api_key_env: "ZAI_API_KEY".to_string(),
518 api_key: None,
519 plan_tier: None,
520 }
521 }
522}
523
524#[derive(Debug, Clone, Deserialize, Serialize)]
525#[serde(default)]
526pub struct OpenRouterConfig {
527 pub enabled: bool,
528 pub accounts: Vec<OpenRouterAccount>,
531 pub show_default_account: bool,
535 pub api_key_env: String,
536 pub api_key: Option<String>,
537}
538
539impl Default for OpenRouterConfig {
540 fn default() -> Self {
541 Self {
542 enabled: true,
543 accounts: Vec::new(),
544 show_default_account: true,
545 api_key_env: "OPENROUTER_API_KEY".to_string(),
546 api_key: None,
547 }
548 }
549}
550
551#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
554pub struct OpenRouterAccount {
555 pub label: String,
557 #[serde(default)]
559 pub api_key_env: Option<String>,
560 #[serde(default)]
562 pub api_key: Option<String>,
563}
564
565impl OpenRouterConfig {
566 pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
569 validate_account_label_for("openrouter", label)?;
570 self.accounts
571 .iter()
572 .find(|account| account.label == label)
573 .ok_or_else(|| {
574 let known: Vec<&str> = self
575 .accounts
576 .iter()
577 .map(|account| account.label.as_str())
578 .collect();
579 AppError::Credentials(format!(
580 "openrouter account {label:?} not found in [[openrouter.accounts]]; \
581 known labels: {known:?}"
582 ))
583 })
584 }
585
586 pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
589 match label {
590 None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
591 Some(label) => {
592 let account = self.account(label)?;
593 resolve_api_key_in_section(
594 &format!("OpenRouter account {label:?}"),
595 "[[openrouter.accounts]]",
596 account.api_key_env.as_deref().unwrap_or(""),
597 account.api_key.as_deref(),
598 )
599 }
600 }
601 }
602}
603
604#[derive(Debug, Clone, Deserialize, Serialize)]
605#[serde(default)]
606pub struct DeepseekConfig {
607 pub enabled: bool,
608 pub api_key_env: String,
609 pub api_key: Option<String>,
610}
611
612impl Default for DeepseekConfig {
613 fn default() -> Self {
614 Self {
615 enabled: false,
616 api_key_env: "DEEPSEEK_API_KEY".to_string(),
617 api_key: None,
618 }
619 }
620}
621
622#[derive(Debug, Clone, Deserialize, Serialize)]
623#[serde(default)]
624pub struct KimiConfig {
625 pub enabled: bool,
626 pub api_key_env: String,
627 pub api_key: Option<String>,
628}
629
630impl Default for KimiConfig {
631 fn default() -> Self {
632 Self {
633 enabled: false,
634 api_key_env: "KIMI_API_KEY".to_string(),
635 api_key: None,
636 }
637 }
638}
639
640#[derive(Debug, Clone, Deserialize, Serialize)]
641#[serde(default)]
642pub struct KiloConfig {
643 pub enabled: bool,
644 pub api_key_env: String,
645 pub api_key: Option<String>,
646 pub organization_id: Option<String>,
649}
650
651impl Default for KiloConfig {
652 fn default() -> Self {
653 Self {
656 enabled: false,
657 api_key_env: "KILO_API_KEY".to_string(),
658 api_key: None,
659 organization_id: None,
660 }
661 }
662}
663
664#[derive(Debug, Clone, Deserialize, Serialize)]
665#[serde(default)]
666pub struct NovitaConfig {
667 pub enabled: bool,
668 pub api_key_env: String,
669 pub api_key: Option<String>,
670}
671
672impl Default for NovitaConfig {
673 fn default() -> Self {
674 Self {
676 enabled: false,
677 api_key_env: "NOVITA_API_KEY".to_string(),
678 api_key: None,
679 }
680 }
681}
682
683#[derive(Debug, Clone, Deserialize, Serialize)]
684#[serde(default)]
685pub struct MinimaxConfig {
686 pub enabled: bool,
687 pub api_key_env: String,
688 pub api_key: Option<String>,
689 pub region: String,
695}
696
697impl Default for MinimaxConfig {
698 fn default() -> Self {
699 Self {
701 enabled: false,
702 api_key_env: "MINIMAX_API_KEY".to_string(),
703 api_key: None,
704 region: "global".to_string(),
705 }
706 }
707}
708
709#[derive(Debug, Clone, Deserialize, Serialize)]
710#[serde(default)]
711pub struct MoonshotConfig {
712 pub enabled: bool,
713 pub api_key_env: String,
714 pub api_key: Option<String>,
715 pub region: String,
717}
718
719impl Default for MoonshotConfig {
720 fn default() -> Self {
721 Self {
723 enabled: false,
724 api_key_env: "MOONSHOT_API_KEY".to_string(),
725 api_key: None,
726 region: "global".to_string(),
727 }
728 }
729}
730
731#[derive(Debug, Clone, Deserialize, Serialize)]
732#[serde(default)]
733pub struct GrokConfig {
734 pub enabled: bool,
735 pub api_key_env: String,
737 pub api_key: Option<String>,
738 pub team_id: Option<String>,
741}
742
743impl Default for GrokConfig {
744 fn default() -> Self {
745 Self {
747 enabled: false,
748 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
749 api_key: None,
750 team_id: None,
751 }
752 }
753}
754
755#[derive(Debug, Clone, Deserialize, Serialize)]
763#[serde(default)]
764pub struct SuperGrokConfig {
765 pub enabled: bool,
766 pub grok_binary: PathBuf,
770 pub auth_path: Option<PathBuf>,
773 pub config_path: Option<PathBuf>,
774}
775
776impl Default for SuperGrokConfig {
777 fn default() -> Self {
778 Self {
779 enabled: false,
780 grok_binary: default_grok_binary(),
781 auth_path: None,
782 config_path: None,
783 }
784 }
785}
786
787fn default_grok_binary() -> PathBuf {
788 let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
789 let grok_home = std::env::var_os("GROK_HOME")
790 .filter(|value| !value.is_empty())
791 .map(PathBuf::from)
792 .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
793 grok_home
794 .map(|home| home.join("bin").join(executable))
795 .unwrap_or_else(|| PathBuf::from(executable))
796}
797
798#[derive(Debug, Clone, Default, Deserialize, Serialize)]
801#[serde(default)]
802pub struct AntigravityConfig {
803 pub enabled: bool,
804}
805
806#[derive(Debug, Clone, Default, Deserialize, Serialize)]
816#[serde(default)]
817pub struct CursorConfig {
818 pub enabled: bool,
819 pub db_path: Option<PathBuf>,
823 pub agent_auth_path: Option<PathBuf>,
828}
829
830#[derive(Debug, Clone, Default, Deserialize, Serialize)]
839#[serde(default)]
840pub struct KiroConfig {
841 pub enabled: bool,
842 pub db_path: Option<PathBuf>,
846}
847
848#[derive(Debug, Clone, Deserialize, Serialize)]
849#[serde(default)]
850pub struct AnthropicApiConfig {
851 pub enabled: bool,
852 pub api_key_env: String,
855 pub api_key: Option<String>,
856 pub monthly_limit: Option<f64>,
859}
860
861impl Default for AnthropicApiConfig {
862 fn default() -> Self {
863 Self {
865 enabled: false,
866 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
867 api_key: None,
868 monthly_limit: None,
869 }
870 }
871}
872
873pub fn resolve_api_key(
876 vendor_label: &str,
877 env_var_name: &str,
878 inline: Option<&str>,
879) -> crate::error::Result<String> {
880 let section = match vendor_label {
881 "OpenCode Go" => "[opencode-go]".to_string(),
882 _ => format!("[{}]", vendor_label.to_lowercase()),
883 };
884 resolve_api_key_in_section(vendor_label, §ion, env_var_name, inline)
885}
886
887fn resolve_api_key_in_section(
888 vendor_label: &str,
889 section: &str,
890 env_var_name: &str,
891 inline: Option<&str>,
892) -> crate::error::Result<String> {
893 let valid_env_name = is_valid_env_var_name(env_var_name);
894 if valid_env_name
895 && let Ok(v) = std::env::var(env_var_name)
896 && !v.is_empty()
897 {
898 return Ok(v);
899 }
900 if let Some(v) = inline
901 && !v.is_empty()
902 {
903 return Ok(v.to_string());
904 }
905 let advice = if valid_env_name {
906 "set an API key in a valid environment variable or set `api_key`"
907 } else {
908 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
909 };
910 Err(crate::error::AppError::Credentials(format!(
911 "{vendor_label}: no API key. Either {advice} under {section} in {}.",
912 config_path_hint()
913 )))
914}
915
916fn is_valid_env_var_name(name: &str) -> bool {
917 let mut chars = name.chars();
918 let Some(first) = chars.next() else {
919 return false;
920 };
921 (first.is_ascii_alphabetic() || first == '_')
922 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
923}
924
925impl Config {
926 pub fn load() -> Result<Self> {
929 let Some(path) = resolved_path() else {
930 return Ok(Self::default());
931 };
932 Self::load_from(&path)
933 }
934
935 pub fn load_from(path: &std::path::Path) -> Result<Self> {
936 match std::fs::read_to_string(path) {
937 Ok(s) => {
938 let mut config: Self = toml::from_str(&s)?;
939 config.expand_paths();
943 config.validate()?;
944 #[cfg(unix)]
945 config.protect_inline_api_keys(path)?;
946 Ok(config)
947 }
948 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
949 Err(e) => Err(AppError::io_at(path, e)),
950 }
951 }
952
953 fn expand_paths(&mut self) {
954 expand_tilde_opt(&mut self.context.projects_path);
955 expand_tilde_opt(&mut self.anthropic.credentials_path);
956 expand_tilde_opt(&mut self.anthropic.accounts_dir);
957 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
958 expand_tilde_opt(&mut self.openai.codex_auth_path);
959 expand_tilde_opt(&mut self.cursor.db_path);
960 expand_tilde_opt(&mut self.cursor.agent_auth_path);
961 expand_tilde_opt(&mut self.kiro.db_path);
962 self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
963 expand_tilde_opt(&mut self.supergrok.auth_path);
964 expand_tilde_opt(&mut self.supergrok.config_path);
965 for account in &mut self.anthropic.accounts {
966 account.credentials_path = expand_tilde(&account.credentials_path);
967 }
968 }
969
970 #[cfg(unix)]
973 fn has_inline_api_keys(&self) -> bool {
974 [
975 self.zai.api_key.as_deref(),
976 self.openrouter.api_key.as_deref(),
977 self.deepseek.api_key.as_deref(),
978 self.kimi.api_key.as_deref(),
979 self.kilo.api_key.as_deref(),
980 self.novita.api_key.as_deref(),
981 self.minimax.api_key.as_deref(),
982 self.moonshot.api_key.as_deref(),
983 self.grok.api_key.as_deref(),
984 self.anthropic_api.api_key.as_deref(),
985 self.opencode_go.api_key.as_deref(),
986 ]
987 .into_iter()
988 .chain(
989 self.openrouter
990 .accounts
991 .iter()
992 .map(|account| account.api_key.as_deref()),
993 )
994 .any(|key| key.is_some_and(|key| !key.is_empty()))
995 }
996
997 #[cfg(unix)]
998 fn protect_inline_api_keys(&self, path: &Path) -> Result<()> {
999 if !self.has_inline_api_keys() {
1000 return Ok(());
1001 }
1002
1003 let metadata = std::fs::metadata(path).map_err(|_| {
1004 AppError::Credentials(format!(
1005 "config at {} contains inline api_key values but its permissions could not be checked; fix permissions or move keys to environment variables",
1006 path.display()
1007 ))
1008 })?;
1009 if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1010 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1011 AppError::Credentials(format!(
1012 "config at {} contains inline api_key values but is group/other-readable and could not be tightened to 0600; fix permissions or move keys to environment variables",
1013 path.display()
1014 ))
1015 })?;
1016 }
1017 Ok(())
1018 }
1019
1020 pub fn is_enabled(&self, id: VendorId) -> bool {
1021 match id {
1022 VendorId::Anthropic => self.anthropic.enabled,
1023 VendorId::AnthropicApi => self.anthropic_api.enabled,
1024 VendorId::Openai => self.openai.enabled,
1025 VendorId::Zai => self.zai.enabled,
1026 VendorId::Openrouter => self.openrouter.enabled,
1027 VendorId::Deepseek => self.deepseek.enabled,
1028 VendorId::Kimi => self.kimi.enabled,
1029 VendorId::Kilo => self.kilo.enabled,
1030 VendorId::Novita => self.novita.enabled,
1031 VendorId::Moonshot => self.moonshot.enabled,
1032 VendorId::Grok => self.grok.enabled,
1033 VendorId::Supergrok => self.supergrok.enabled,
1034 VendorId::Antigravity => self.antigravity.enabled,
1035 VendorId::Cursor => self.cursor.enabled,
1036 VendorId::Minimax => self.minimax.enabled,
1037 VendorId::Kiro => self.kiro.enabled,
1038 VendorId::NousResearch => self.nous.enabled,
1039 VendorId::OpenCodeGo => self.opencode_go.enabled,
1040 }
1041 }
1042
1043 pub fn enabled_vendors(&self) -> Vec<VendorId> {
1044 VendorId::all()
1045 .iter()
1046 .copied()
1047 .filter(|id| self.is_enabled(*id))
1048 .collect()
1049 }
1050
1051 pub fn validate(&self) -> Result<()> {
1055 if self.context.context_window_tokens == Some(0) {
1056 return Err(AppError::Other(
1057 "[context] context_window_tokens must be greater than zero".into(),
1058 ));
1059 }
1060 for (model, tokens) in &self.context.model_context_window_tokens {
1061 if model.trim().is_empty() {
1062 return Err(AppError::Other(
1063 "[context] model_context_window_tokens keys must not be empty".into(),
1064 ));
1065 }
1066 if *tokens == 0 {
1067 return Err(AppError::Other(format!(
1068 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1069 )));
1070 }
1071 }
1072 if let Some(limit) = self.anthropic_api.monthly_limit
1073 && (!limit.is_finite() || limit <= 0.0)
1074 {
1075 return Err(AppError::Other(
1076 "[anthropic_api] monthly_limit must be finite and greater than zero; \
1077 remove it to show spend without a limit"
1078 .into(),
1079 ));
1080 }
1081 if !self.minimax.region.eq_ignore_ascii_case("global")
1082 && !self.minimax.region.eq_ignore_ascii_case("cn")
1083 {
1084 return Err(AppError::Other(format!(
1085 "[minimax] region must be \"global\" or \"cn\", got {:?}",
1086 self.minimax.region
1087 )));
1088 }
1089 if self.supergrok.grok_binary.as_os_str().is_empty() {
1090 return Err(AppError::Other(
1091 "[supergrok] grok_binary must not be empty".into(),
1092 ));
1093 }
1094 let mut labels = HashSet::new();
1095 for account in &self.anthropic.accounts {
1096 validate_account_label(&account.label)?;
1097 if !labels.insert(&account.label) {
1098 return Err(AppError::Credentials(format!(
1099 "duplicate anthropic account label {:?}",
1100 account.label
1101 )));
1102 }
1103 }
1104 let mut openrouter_labels = HashSet::new();
1105 for account in &self.openrouter.accounts {
1106 validate_account_label_for("openrouter", &account.label)?;
1107 if !openrouter_labels.insert(&account.label) {
1108 return Err(AppError::Credentials(format!(
1109 "duplicate openrouter account label {:?}",
1110 account.label
1111 )));
1112 }
1113 let has_env = account
1114 .api_key_env
1115 .as_deref()
1116 .is_some_and(|name| !name.is_empty());
1117 let has_inline = account
1118 .api_key
1119 .as_deref()
1120 .is_some_and(|key| !key.is_empty());
1121 if !has_env && !has_inline {
1122 return Err(AppError::Credentials(format!(
1123 "openrouter account {:?} must set api_key_env or api_key",
1124 account.label
1125 )));
1126 }
1127 }
1128 Ok(())
1129 }
1130}
1131
1132#[cfg(unix)]
1133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1134enum InlineKeyPermissionDecision {
1135 Ok,
1136 Tighten,
1137}
1138
1139#[cfg(unix)]
1140fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
1141 if mode & 0o077 == 0 {
1142 InlineKeyPermissionDecision::Ok
1143 } else {
1144 InlineKeyPermissionDecision::Tighten
1145 }
1146}
1147
1148pub fn default_path() -> Option<PathBuf> {
1149 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
1150 Some(proj.config_dir().join("config.toml"))
1151}
1152
1153fn legacy_xdg_path() -> Option<PathBuf> {
1158 let home = crate::cache::home_dir().ok()?;
1159 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
1160}
1161
1162pub fn resolved_path() -> Option<PathBuf> {
1171 let canonical = default_path();
1172 if let Some(p) = &canonical
1173 && p.exists()
1174 {
1175 return canonical;
1176 }
1177 if let Some(legacy) = legacy_xdg_path()
1178 && legacy.exists()
1179 {
1180 return Some(legacy);
1181 }
1182 canonical
1183}
1184
1185fn expand_tilde(p: &std::path::Path) -> PathBuf {
1188 let Some(s) = p.to_str() else {
1189 return p.to_path_buf();
1190 };
1191 let rest = if s == "~" {
1192 ""
1193 } else if let Some(r) = s.strip_prefix("~/") {
1194 r
1195 } else {
1196 return p.to_path_buf();
1197 };
1198 match crate::cache::home_dir() {
1199 Ok(home) if rest.is_empty() => home,
1200 Ok(home) => home.join(rest),
1201 Err(_) => p.to_path_buf(),
1202 }
1203}
1204
1205fn expand_tilde_opt(p: &mut Option<PathBuf>) {
1206 if let Some(inner) = p.as_ref() {
1207 *p = Some(expand_tilde(inner));
1208 }
1209}
1210
1211pub fn config_path_hint() -> String {
1216 resolved_path()
1217 .map(|p| p.display().to_string())
1218 .unwrap_or_else(|| "config.toml".to_string())
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use std::io::Write;
1225 use tempfile::NamedTempFile;
1226
1227 #[cfg(unix)]
1228 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1229
1230 fn write_toml(s: &str) -> NamedTempFile {
1231 let mut f = NamedTempFile::new().unwrap();
1232 f.write_all(s.as_bytes()).unwrap();
1233 f.flush().unwrap();
1234 f
1235 }
1236
1237 #[test]
1238 fn defaults_enable_only_the_four_core_vendors() {
1239 let c = Config::default();
1240 assert!(c.is_enabled(VendorId::Anthropic));
1241 assert!(c.is_enabled(VendorId::Openai));
1242 assert!(c.is_enabled(VendorId::Zai));
1243 assert!(c.is_enabled(VendorId::Openrouter));
1244 for opt_in in [
1245 VendorId::AnthropicApi,
1246 VendorId::Deepseek,
1247 VendorId::Kimi,
1248 VendorId::Kilo,
1249 VendorId::Novita,
1250 VendorId::Moonshot,
1251 VendorId::Grok,
1252 VendorId::Supergrok,
1253 VendorId::Cursor,
1254 VendorId::Minimax,
1255 VendorId::Kiro,
1256 ] {
1257 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
1258 }
1259 assert_eq!(c.enabled_vendors().len(), 4);
1260 }
1261
1262 #[test]
1263 fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
1264 let config = Config::default();
1265 assert!(!config.is_enabled(VendorId::NousResearch));
1266 assert!(!config.is_enabled(VendorId::OpenCodeGo));
1267 assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
1268 assert!(config.opencode_go.api_key.is_none());
1269 }
1270
1271 #[cfg(unix)]
1272 #[test]
1273 fn opencode_go_inline_key_is_protected_like_other_api_keys() {
1274 let mut config = Config::default();
1275 config.opencode_go.api_key = Some("<redacted>".to_string());
1276 assert!(config.has_inline_api_keys());
1277 }
1278
1279 #[cfg(unix)]
1280 #[test]
1281 fn openrouter_named_inline_keys_receive_config_file_protection() {
1282 let mut config = Config::default();
1283 config.openrouter.accounts.push(OpenRouterAccount {
1284 label: "work".into(),
1285 api_key_env: None,
1286 api_key: Some("<redacted>".into()),
1287 });
1288 assert!(config.has_inline_api_keys());
1289 }
1290
1291 #[test]
1292 fn missing_file_uses_defaults() {
1293 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
1294 let c = Config::load_from(path).unwrap();
1295 assert!(c.is_enabled(VendorId::Anthropic));
1296 }
1297
1298 #[test]
1299 fn parses_full_config() {
1300 let f = write_toml(
1301 r#"
1302 [anthropic]
1303 enabled = true
1304
1305 [openai]
1306 enabled = false
1307 admin_key_env = "MY_ADMIN_KEY"
1308
1309 [zai]
1310 enabled = true
1311 api_key_env = "MY_ZAI"
1312 plan_tier = "pro"
1313
1314 [openrouter]
1315 enabled = false
1316 "#,
1317 );
1318 let c = Config::load_from(f.path()).unwrap();
1319 assert!(c.is_enabled(VendorId::Anthropic));
1320 assert!(!c.is_enabled(VendorId::Openai));
1321 assert!(c.is_enabled(VendorId::Zai));
1322 assert!(!c.is_enabled(VendorId::Openrouter));
1323 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1324 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1325 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1326 assert!(c.openrouter.accounts.is_empty());
1327 assert!(c.openrouter.show_default_account);
1328 }
1329
1330 #[test]
1331 fn partial_config_falls_back_to_defaults() {
1332 let f = write_toml(
1333 r#"[openai]
1334enabled = false
1335"#,
1336 );
1337 let c = Config::load_from(f.path()).unwrap();
1338 assert!(!c.is_enabled(VendorId::Openai));
1339 assert!(c.is_enabled(VendorId::Anthropic));
1341 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1342 }
1343
1344 #[test]
1345 fn malformed_toml_returns_error() {
1346 let f = write_toml("this is not = = valid");
1347 assert!(Config::load_from(f.path()).is_err());
1348 }
1349
1350 #[cfg(unix)]
1351 #[test]
1352 fn load_from_tightens_world_readable_config_with_inline_api_key() {
1353 let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
1354 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1355
1356 Config::load_from(file.path()).unwrap();
1357
1358 assert_eq!(
1359 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1360 0o600
1361 );
1362 }
1363
1364 #[cfg(unix)]
1365 #[test]
1366 fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
1367 let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
1368 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
1369
1370 Config::load_from(file.path()).unwrap();
1371
1372 assert_eq!(
1373 std::fs::metadata(file.path()).unwrap().mode() & 0o777,
1374 0o644
1375 );
1376 }
1377
1378 #[cfg(unix)]
1379 #[test]
1380 fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
1381 assert_eq!(
1382 inline_key_permission_decision(0o600),
1383 InlineKeyPermissionDecision::Ok
1384 );
1385 assert_eq!(
1386 inline_key_permission_decision(0o640),
1387 InlineKeyPermissionDecision::Tighten
1388 );
1389 assert_eq!(
1390 inline_key_permission_decision(0o604),
1391 InlineKeyPermissionDecision::Tighten
1392 );
1393 }
1394
1395 #[test]
1396 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1397 for value in ["0", "-1", "inf", "nan"] {
1398 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1399 let error = Config::load_from(file.path()).unwrap_err().to_string();
1400 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1401 }
1402
1403 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1404 assert_eq!(
1405 Config::load_from(file.path())
1406 .unwrap()
1407 .anthropic_api
1408 .monthly_limit,
1409 Some(1000.0)
1410 );
1411 }
1412
1413 #[test]
1414 fn minimax_region_accepts_only_known_instances() {
1415 for region in ["global", "GLOBAL", "cn", "CN"] {
1416 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1417 assert_eq!(
1418 Config::load_from(file.path()).unwrap().minimax.region,
1419 region
1420 );
1421 }
1422
1423 for region in ["", "china", "us"] {
1424 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1425 let error = Config::load_from(file.path()).unwrap_err().to_string();
1426 assert!(error.contains("[minimax] region"), "{error}");
1427 }
1428 }
1429
1430 #[test]
1431 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1432 let defaults = Config::default();
1433 assert!(!defaults.context.enabled);
1434 assert_eq!(
1435 defaults.context.window_tokens_for(Some("claude-test")),
1436 None
1437 );
1438
1439 let file = write_toml(
1440 r#"
1441 [context]
1442 enabled = true
1443 context_window_tokens = 200000
1444
1445 [context.model_context_window_tokens]
1446 claude-opus-1m = 1000000
1447 "claude exact id" = 300000
1448 "#,
1449 );
1450 let config = Config::load_from(file.path()).unwrap();
1451 assert!(config.context.enabled);
1452 assert_eq!(
1453 config.context.window_tokens_for(Some("claude-opus-1m")),
1454 Some(1_000_000)
1455 );
1456 assert_eq!(
1457 config.context.window_tokens_for(Some("claude exact id")),
1458 Some(300_000)
1459 );
1460 assert_eq!(
1461 config.context.window_tokens_for(Some("another-model")),
1462 Some(200_000)
1463 );
1464 }
1465
1466 #[test]
1467 fn context_layout_defaults_to_full_and_parses_each_variant() {
1468 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1469 for (text, want) in [
1470 ("full", ContextLayout::Full),
1471 ("split", ContextLayout::Split),
1472 ("bottom", ContextLayout::Bottom),
1473 ] {
1474 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1475 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1476 }
1477 let file = write_toml("[context]\nlayout = \"floating\"\n");
1478 assert!(
1479 Config::load_from(file.path()).is_err(),
1480 "an unknown layout must be rejected, not silently defaulted"
1481 );
1482 }
1483
1484 #[test]
1485 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1486 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1487 for (text, want) in [
1488 ("sidebar", VendorBoxStyle::Sidebar),
1489 ("navbar", VendorBoxStyle::Navbar),
1490 ("none", VendorBoxStyle::None),
1491 ] {
1492 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1493 assert_eq!(
1494 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1495 want
1496 );
1497 }
1498 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1499 assert!(
1500 Config::load_from(file.path()).is_err(),
1501 "an unknown vendor_box style must be rejected, not silently defaulted"
1502 );
1503 }
1504
1505 #[test]
1506 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1507 for source in [
1508 "[context]\ncontext_window_tokens = 0\n",
1509 "[context.model_context_window_tokens]\nclaude = 0\n",
1510 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1511 ] {
1512 let file = write_toml(source);
1513 let error = Config::load_from(file.path()).unwrap_err().to_string();
1514 assert!(error.contains("context"), "{error}");
1515 }
1516 }
1517
1518 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1520 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1521 M.lock().unwrap_or_else(|p| p.into_inner())
1522 }
1523
1524 #[test]
1525 fn resolve_api_key_prefers_env_over_inline() {
1526 let _g = env_guard();
1527 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1529 unsafe { std::env::set_var(var, "from-env") };
1531 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1532 unsafe { std::env::remove_var(var) };
1533 assert_eq!(got, "from-env");
1534 }
1535
1536 #[test]
1537 fn resolve_api_key_falls_back_to_inline() {
1538 let _g = env_guard();
1539 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1540 unsafe { std::env::remove_var(var) };
1541 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1542 assert_eq!(got, "inline-key");
1543 }
1544
1545 #[test]
1546 fn resolve_api_key_errors_when_both_missing() {
1547 let _g = env_guard();
1548 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1549 unsafe { std::env::remove_var(var) };
1550 let err = resolve_api_key("Zai", var, None).unwrap_err();
1551 match err {
1552 crate::error::AppError::Credentials(msg) => {
1553 assert!(
1554 msg.contains("api_key"),
1555 "error should suggest config field: {msg}"
1556 );
1557 }
1558 other => panic!("expected Credentials error, got {other:?}"),
1559 }
1560 }
1561
1562 #[test]
1563 fn resolve_api_key_uses_exact_opencode_go_section_name() {
1564 let _g = env_guard();
1565 unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
1566 let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
1567 let message = err.to_string();
1568 assert!(
1569 message.contains("[opencode-go]"),
1570 "wrong section hint: {message}"
1571 );
1572 assert!(
1573 !message.contains("[opencode go]"),
1574 "wrong section hint: {message}"
1575 );
1576 }
1577
1578 #[test]
1579 fn config_path_hint_ends_with_config_toml() {
1580 assert!(config_path_hint().ends_with("config.toml"));
1583 }
1584
1585 #[test]
1586 fn resolve_api_key_treats_empty_env_as_unset() {
1587 let _g = env_guard();
1588 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1589 unsafe { std::env::set_var(var, "") };
1590 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1591 unsafe { std::env::remove_var(var) };
1592 assert_eq!(got, "inline");
1593 }
1594
1595 #[test]
1596 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1597 let _g = env_guard();
1598 let bad = "sk-kimi-very-real-looking-pasted-secret";
1600 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1601 let msg = err.to_string();
1602 assert!(
1603 msg.contains("invalid") && msg.contains("api_key_env"),
1604 "error should explain misconfiguration: {msg}"
1605 );
1606 assert!(
1607 !msg.contains(bad),
1608 "error must not echo the misconfigured value: {msg}"
1609 );
1610 assert!(msg.contains("valid environment variable name"));
1611 assert!(
1612 msg.contains("[kimi]"),
1613 "error should point at the lowercase TOML section: {msg}"
1614 );
1615 }
1616
1617 #[test]
1618 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1619 let _g = env_guard();
1620 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1621 assert_eq!(got, "inline-key");
1622 }
1623
1624 #[test]
1625 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1626 let _g = env_guard();
1627 let pasted_secret = "sk_pasted_secret";
1630 unsafe { std::env::remove_var(pasted_secret) };
1631 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1632 assert!(
1633 !err.to_string().contains(pasted_secret),
1634 "error must not echo configured api_key_env values"
1635 );
1636 }
1637
1638 #[test]
1639 fn is_valid_env_var_name_rules() {
1640 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1642 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1643 }
1644 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1646 assert!(
1647 !is_valid_env_var_name(invalid),
1648 "{invalid} should be invalid"
1649 );
1650 }
1651 }
1652
1653 #[test]
1654 fn config_parses_with_inline_api_key_and_primary() {
1655 let f = write_toml(
1656 r#"
1657 [ui]
1658 primary = "openrouter"
1659
1660 [zai]
1661 enabled = true
1662 api_key_env = "MY_ZAI"
1663 api_key = "sk-zai-inline"
1664
1665 [openrouter]
1666 enabled = true
1667 api_key = "sk-or-inline"
1668 "#,
1669 );
1670 let c = Config::load_from(f.path()).unwrap();
1671 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1672 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1673 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1674 }
1675
1676 #[test]
1677 fn openrouter_named_accounts_preserve_the_default_contract() {
1678 let f = write_toml(
1679 r#"
1680 [openrouter]
1681 enabled = true
1682 api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
1683 api_key = "default-inline"
1684 show_default_account = false
1685
1686 [[openrouter.accounts]]
1687 label = "work"
1688 api_key_env = "OPENROUTER_WORK_API_KEY"
1689
1690 [[openrouter.accounts]]
1691 label = "personal"
1692 api_key = "personal-inline"
1693 "#,
1694 );
1695 let _g = env_guard();
1696 unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
1697 let config = Config::load_from(f.path()).unwrap();
1698 assert!(!config.openrouter.show_default_account);
1699 assert_eq!(config.openrouter.accounts.len(), 2);
1700 assert_eq!(
1701 config.openrouter.resolve_api_key(None).unwrap(),
1702 "default-inline"
1703 );
1704 assert_eq!(
1705 config.openrouter.resolve_api_key(Some("personal")).unwrap(),
1706 "personal-inline"
1707 );
1708 }
1709
1710 #[test]
1711 fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
1712 for source in [
1713 r#"
1714 [[openrouter.accounts]]
1715 label = "work"
1716 api_key = "one"
1717 [[openrouter.accounts]]
1718 label = "work"
1719 api_key = "two"
1720 "#,
1721 r#"
1722 [[openrouter.accounts]]
1723 label = "../work"
1724 api_key = "one"
1725 "#,
1726 r#"
1727 [[openrouter.accounts]]
1728 label = "work"
1729 "#,
1730 ] {
1731 let f = write_toml(source);
1732 assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
1733 }
1734 }
1735
1736 #[test]
1737 fn openrouter_unknown_account_never_falls_back_to_default_key() {
1738 let mut config = OpenRouterConfig {
1739 api_key: Some("default-secret".into()),
1740 ..OpenRouterConfig::default()
1741 };
1742 config.accounts.push(OpenRouterAccount {
1743 label: "work".into(),
1744 api_key_env: None,
1745 api_key: Some("work-secret".into()),
1746 });
1747 let message = config
1748 .resolve_api_key(Some("missing"))
1749 .unwrap_err()
1750 .to_string();
1751 assert!(message.contains("missing") && message.contains("work"));
1752 assert!(!message.contains("default-secret"));
1753 assert!(!message.contains("work-secret"));
1754 }
1755
1756 #[test]
1757 fn openrouter_account_key_errors_do_not_echo_configured_values() {
1758 let config = OpenRouterConfig {
1759 accounts: vec![OpenRouterAccount {
1760 label: "work".into(),
1761 api_key_env: Some("sk_pasted_secret".into()),
1762 api_key: None,
1763 }],
1764 ..OpenRouterConfig::default()
1765 };
1766 let _g = env_guard();
1767 unsafe { std::env::remove_var("sk_pasted_secret") };
1768 let message = config
1769 .resolve_api_key(Some("work"))
1770 .unwrap_err()
1771 .to_string();
1772 assert!(message.contains("[[openrouter.accounts]]"));
1773 assert!(!message.contains("sk_pasted_secret"));
1774 }
1775
1776 #[test]
1777 fn enabled_vendors_preserves_canonical_order() {
1778 let c = Config::default();
1781 assert_eq!(
1782 c.enabled_vendors(),
1783 vec![
1784 VendorId::Anthropic,
1785 VendorId::Openai,
1786 VendorId::Zai,
1787 VendorId::Openrouter,
1788 ]
1789 );
1790 }
1791
1792 #[test]
1793 fn deepseek_appears_when_enabled() {
1794 let f = write_toml(
1795 r#"
1796 [deepseek]
1797 enabled = true
1798 api_key = "sk-test"
1799 "#,
1800 );
1801 let c = Config::load_from(f.path()).unwrap();
1802 assert!(c.is_enabled(VendorId::Deepseek));
1803 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1804 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1805 }
1806
1807 #[test]
1808 fn tilde_paths_are_expanded_on_load() {
1809 let f = write_toml(
1813 r#"
1814 [context]
1815 projects_path = "~/.claude/projects"
1816
1817 [anthropic]
1818 credentials_path = "~/.claude/.credentials.json"
1819
1820 [[anthropic.accounts]]
1821 label = "work"
1822 credentials_path = "~/work.json"
1823 "#,
1824 );
1825 let c = Config::load_from(f.path()).unwrap();
1826 let home = crate::cache::home_dir().unwrap();
1827
1828 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1829 let got = c.anthropic.credentials_path.unwrap();
1830 assert_eq!(got, home.join(".claude/.credentials.json"));
1831 assert!(!got.to_string_lossy().contains('~'));
1832 assert_eq!(
1833 c.anthropic.accounts[0].credentials_path,
1834 home.join("work.json")
1835 );
1836 }
1837
1838 #[test]
1839 fn absolute_and_relative_paths_are_left_alone() {
1840 let f = write_toml(
1841 r#"
1842 [anthropic]
1843 credentials_path = "/etc/creds.json"
1844 "#,
1845 );
1846 let c = Config::load_from(f.path()).unwrap();
1847 assert_eq!(
1848 c.anthropic.credentials_path.unwrap(),
1849 std::path::Path::new("/etc/creds.json")
1850 );
1851
1852 let f2 = write_toml(
1854 r#"
1855 [anthropic]
1856 credentials_path = "~someone/creds.json"
1857 "#,
1858 );
1859 let c2 = Config::load_from(f2.path()).unwrap();
1860 assert_eq!(
1861 c2.anthropic.credentials_path.unwrap(),
1862 std::path::Path::new("~someone/creds.json")
1863 );
1864 }
1865
1866 #[test]
1867 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1868 let p = resolved_path().expect("a config path must resolve");
1871 assert!(p.ends_with("config.toml"));
1872 let canonical = default_path().unwrap();
1873 let legacy = legacy_xdg_path().unwrap();
1874 assert!(
1875 p == canonical || p == legacy,
1876 "resolved to an unexpected location: {}",
1877 p.display()
1878 );
1879 }
1880
1881 #[test]
1882 fn misspelled_section_is_rejected_not_ignored() {
1883 let f = write_toml(
1886 r#"
1887 [openrouer]
1888 enabled = true
1889 api_key = "sk-or-v1-typo"
1890 "#,
1891 );
1892 let err = Config::load_from(f.path()).unwrap_err().to_string();
1893 assert!(
1894 err.contains("openrouer"),
1895 "error should name the typo: {err}"
1896 );
1897 }
1898
1899 #[test]
1900 fn invalid_toml_is_an_error_not_silent_defaults() {
1901 let f = write_toml("[zai\nenabled = true\n");
1902 assert!(Config::load_from(f.path()).is_err());
1903 }
1904
1905 #[test]
1906 fn a_missing_file_is_still_just_defaults() {
1907 let dir = tempfile::tempdir().unwrap();
1910 let missing = dir.path().join("nope").join("config.toml");
1911 let c = Config::load_from(&missing).unwrap();
1912 assert!(c.is_enabled(VendorId::Anthropic));
1913 }
1914
1915 #[test]
1916 fn kimi_appears_when_enabled() {
1917 let f = write_toml(
1918 r#"
1919 [kimi]
1920 enabled = true
1921 api_key = "sk-test"
1922 "#,
1923 );
1924 let c = Config::load_from(f.path()).unwrap();
1925 assert!(c.is_enabled(VendorId::Kimi));
1926 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1927 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1928 }
1929
1930 #[test]
1931 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1932 let f = write_toml(
1933 r#"
1934 [deepseek]
1935 enabled = true
1936 api_key = "sk-ds"
1937
1938 [kimi]
1939 enabled = true
1940 api_key = "sk-kimi"
1941 "#,
1942 );
1943 let c = Config::load_from(f.path()).unwrap();
1944 assert_eq!(
1945 c.enabled_vendors(),
1946 vec![
1947 VendorId::Anthropic,
1948 VendorId::Openai,
1949 VendorId::Zai,
1950 VendorId::Openrouter,
1951 VendorId::Deepseek,
1952 VendorId::Kimi,
1953 ]
1954 );
1955 }
1956
1957 #[test]
1958 fn parses_anthropic_accounts_and_looks_them_up() {
1959 let f = write_toml(
1960 r#"
1961 [anthropic]
1962 enabled = true
1963
1964 [[anthropic.accounts]]
1965 label = "personal"
1966 credentials_path = "/creds/personal.json"
1967
1968 [[anthropic.accounts]]
1969 label = "work"
1970 credentials_path = "/creds/work.json"
1971 "#,
1972 );
1973 let c = Config::load_from(f.path()).unwrap();
1974 assert_eq!(c.anthropic.accounts.len(), 2);
1975 let work = c.anthropic.account("work").unwrap();
1976 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1977 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1979 assert!(err.contains("missing") && err.contains("work"), "{err}");
1980 }
1981
1982 #[test]
1983 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1984 let f = write_toml(
1985 r#"
1986 [[anthropic.accounts]]
1987 label = "work"
1988 credentials_path = "/creds/work-one.json"
1989
1990 [[anthropic.accounts]]
1991 label = "work"
1992 credentials_path = "/creds/work-two.json"
1993 "#,
1994 );
1995 let err = Config::load_from(f.path()).unwrap_err().to_string();
1996 assert!(
1997 err.contains("duplicate anthropic account label \"work\""),
1998 "{err}"
1999 );
2000 }
2001
2002 #[test]
2003 fn account_label_rejects_path_like_names() {
2004 let cfg = AnthropicConfig::default();
2005 for bad in [
2006 "",
2007 ".",
2008 "..",
2009 "a/b",
2010 r"a\b",
2011 "C:work",
2012 "line\nbreak",
2013 "tab\tname",
2014 "usage.json",
2015 ".stale",
2016 ".last_error",
2017 ".fetch.lock",
2018 ] {
2019 let err = cfg.account(bad).unwrap_err();
2020 assert!(
2021 format!("{err:?}").contains("invalid anthropic account label"),
2022 "{bad:?} should be rejected as a label"
2023 );
2024 }
2025 }
2026
2027 #[test]
2028 fn anthropic_accounts_default_to_empty() {
2029 assert!(Config::default().anthropic.accounts.is_empty());
2032 assert!(Config::default().anthropic.accounts_dir.is_none());
2033 }
2034
2035 fn seed_account_dir(root: &std::path::Path, label: &str) {
2041 let dir = root.join(label);
2042 std::fs::create_dir_all(&dir).unwrap();
2043 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
2044 }
2045
2046 #[test]
2047 fn discovers_account_dirs_in_claude_config_dir_layout() {
2048 let td = tempfile::tempdir().unwrap();
2049 seed_account_dir(td.path(), "work");
2050 seed_account_dir(td.path(), "personal");
2051 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
2054 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
2056
2057 let cfg = AnthropicConfig {
2058 accounts_dir: Some(td.path().to_path_buf()),
2059 ..Default::default()
2060 };
2061 let all = cfg.all_accounts();
2062 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
2063 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
2064 assert_eq!(
2065 all[2].credentials_path,
2066 td.path().join("work").join(".credentials.json")
2067 );
2068 }
2069
2070 #[test]
2071 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
2072 let td = tempfile::tempdir().unwrap();
2073 seed_account_dir(td.path(), "work");
2074 let cfg = AnthropicConfig {
2075 accounts: vec![AnthropicAccount {
2076 label: "work".into(),
2077 credentials_path: "/explicit/work.json".into(),
2078 }],
2079 accounts_dir: Some(td.path().to_path_buf()),
2080 ..Default::default()
2081 };
2082 let all = cfg.all_accounts();
2083 assert_eq!(all.len(), 1, "no duplicate label");
2084 assert_eq!(
2085 all[0].credentials_path,
2086 std::path::Path::new("/explicit/work.json"),
2087 "explicit entry wins"
2088 );
2089 seed_account_dir(td.path(), "other");
2091 assert_eq!(cfg.account("other").unwrap().label, "other");
2092 }
2093
2094 #[test]
2095 fn missing_accounts_dir_is_silently_empty_not_an_error() {
2096 let cfg = AnthropicConfig {
2097 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
2098 ..Default::default()
2099 };
2100 assert!(cfg.all_accounts().is_empty());
2101 }
2102
2103 #[test]
2104 fn accounts_dir_is_tilde_expanded_on_load() {
2105 let f = write_toml(
2106 r#"
2107 [anthropic]
2108 accounts_dir = "~/.config/ai-usagebar/accounts"
2109 "#,
2110 );
2111 let c = Config::load_from(f.path()).unwrap();
2112 let home = crate::cache::home_dir().unwrap();
2113 assert_eq!(
2114 c.anthropic.accounts_dir,
2115 Some(home.join(".config/ai-usagebar/accounts"))
2116 );
2117 }
2118
2119 #[test]
2120 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
2121 let f = write_toml(
2122 r#"
2123 [anthropic]
2124 desktop_profiles_dir = "~/.claude-acc/profiles"
2125 "#,
2126 );
2127 let c = Config::load_from(f.path()).unwrap();
2128 let home = crate::cache::home_dir().unwrap();
2129 assert_eq!(
2130 c.anthropic.desktop_profiles_dir,
2131 Some(home.join(".claude-acc/profiles"))
2132 );
2133 }
2134
2135 #[test]
2136 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
2137 let cfg = AnthropicConfig {
2138 accounts: vec![
2139 AnthropicAccount {
2140 label: "work".into(),
2141 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2142 },
2143 AnthropicAccount {
2144 label: "personal".into(),
2145 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
2146 },
2147 ],
2148 ..Default::default()
2149 };
2150
2151 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
2152 assert!(
2153 matches!(&idle, CredsTarget::Named { config_dir, .. }
2154 if config_dir == std::path::Path::new("/tmp/accounts/work")),
2155 "{idle:?}"
2156 );
2157
2158 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
2160 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
2161
2162 assert_eq!(idle_cache.dir(), live_cache.dir());
2165 }
2166
2167 #[test]
2168 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
2169 let cfg = AnthropicConfig {
2170 accounts: vec![AnthropicAccount {
2171 label: "work".into(),
2172 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
2173 }],
2174 ..Default::default()
2175 };
2176 let (target, _) = cfg.account_target_with("work", None).unwrap();
2177 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
2178 }
2179
2180 fn config_example() -> PathBuf {
2184 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
2185 }
2186
2187 #[test]
2188 fn shipped_example_parses_as_a_real_config() {
2189 let c = Config::load_from(&config_example()).unwrap();
2194 assert!(!c.context.enabled);
2195 assert!(c.is_enabled(VendorId::Anthropic));
2196 assert!(c.is_enabled(VendorId::Openai));
2197 assert!(!c.is_enabled(VendorId::AnthropicApi));
2198 assert!(!c.is_enabled(VendorId::Deepseek));
2199 assert!(!c.is_enabled(VendorId::Kimi));
2200 assert!(!c.is_enabled(VendorId::Kilo));
2201 assert!(!c.is_enabled(VendorId::Novita));
2202 assert!(!c.is_enabled(VendorId::Moonshot));
2203 assert!(!c.is_enabled(VendorId::Grok));
2204 assert!(!c.is_enabled(VendorId::Cursor));
2205 assert!(!c.is_enabled(VendorId::Minimax));
2206 }
2207
2208 #[test]
2209 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
2210 let text = std::fs::read_to_string(config_example()).unwrap();
2215 let live: Vec<&str> = text
2216 .lines()
2217 .map(str::trim)
2218 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
2219 .collect();
2220 assert!(
2221 live.is_empty(),
2222 "admin_key_env must stay commented out while it is inert: {live:?}"
2223 );
2224 assert!(
2227 text.contains("admin_key_env") && text.contains("RESERVED"),
2228 "the example should keep describing admin_key_env as reserved"
2229 );
2230 }
2231
2232 #[test]
2233 fn admin_key_env_is_accepted_but_changes_nothing() {
2234 let f = write_toml(
2238 r#"
2239 [openai]
2240 admin_key_env = "SOME_ADMIN_KEY"
2241 "#,
2242 );
2243 let c = Config::load_from(f.path()).unwrap();
2244 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
2245 let default = OpenAiConfig::default();
2247 assert_eq!(c.openai.enabled, default.enabled);
2248 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
2249 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
2250 }
2251
2252 #[test]
2253 fn config_example_documents_every_vendor_without_secrets() {
2254 let raw = std::fs::read_to_string(config_example()).unwrap();
2255 let cfg = Config::load_from(&config_example()).unwrap();
2256 for id in VendorId::all() {
2259 let section = id.slug();
2260 assert!(
2261 raw.contains(&format!("[{section}]")),
2262 "config.example.toml has no [{section}] section"
2263 );
2264 }
2265
2266 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
2269 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
2270 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
2271 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
2272 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
2273 assert!(!cfg.supergrok.enabled);
2274 assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
2275 assert_eq!(
2276 cfg.supergrok
2277 .grok_binary
2278 .file_name()
2279 .and_then(|p| p.to_str()),
2280 Some(if cfg!(windows) { "grok.exe" } else { "grok" })
2281 );
2282 assert!(cfg.supergrok.auth_path.is_none());
2283 assert!(cfg.supergrok.config_path.is_none());
2284 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
2285 assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
2286 }
2287
2288 #[test]
2289 fn supergrok_binary_must_not_be_empty() {
2290 let file = write_toml(
2291 r#"
2292 [supergrok]
2293 enabled = true
2294 grok_binary = ""
2295 "#,
2296 );
2297 let error = Config::load_from(file.path()).unwrap_err().to_string();
2298 assert!(error.contains("grok_binary must not be empty"));
2299 }
2300
2301 #[test]
2302 fn supergrok_paths_are_tilde_expanded() {
2303 let file = write_toml(
2304 r#"
2305 [supergrok]
2306 grok_binary = "~/bin/grok"
2307 auth_path = "~/.grok/auth.json"
2308 config_path = "~/.grok/config.toml"
2309 "#,
2310 );
2311 let config = Config::load_from(file.path()).unwrap();
2312 let home = crate::cache::home_dir().unwrap();
2313 assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
2314 assert_eq!(
2315 config.supergrok.auth_path,
2316 Some(home.join(".grok/auth.json"))
2317 );
2318 assert_eq!(
2319 config.supergrok.config_path,
2320 Some(home.join(".grok/config.toml"))
2321 );
2322 }
2323
2324 #[test]
2325 fn kiro_db_path_is_tilde_expanded() {
2326 let f = write_toml(
2327 r#"
2328 [kiro]
2329 db_path = "~/kiro-data.sqlite3"
2330 "#,
2331 );
2332 let c = Config::load_from(f.path()).unwrap();
2333 let home = crate::cache::home_dir().unwrap();
2334 assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
2335 }
2336
2337 #[test]
2338 fn kiro_appears_when_enabled() {
2339 let f = write_toml(
2340 r#"
2341 [kiro]
2342 enabled = true
2343 "#,
2344 );
2345 let c = Config::load_from(f.path()).unwrap();
2346 assert!(c.is_enabled(VendorId::Kiro));
2347 assert!(c.enabled_vendors().contains(&VendorId::Kiro));
2348 }
2349
2350 #[test]
2351 fn cursor_db_path_is_tilde_expanded() {
2352 let f = write_toml(
2353 r#"
2354 [cursor]
2355 db_path = "~/cursor-state.vscdb"
2356 "#,
2357 );
2358 let c = Config::load_from(f.path()).unwrap();
2359 let home = crate::cache::home_dir().unwrap();
2360 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
2361 }
2362
2363 #[test]
2364 fn cursor_agent_auth_path_is_tilde_expanded() {
2365 let f = write_toml(
2366 r#"
2367 [cursor]
2368 agent_auth_path = "~/cursor-agent-auth.json"
2369 "#,
2370 );
2371 let c = Config::load_from(f.path()).unwrap();
2372 let home = crate::cache::home_dir().unwrap();
2373 assert_eq!(
2374 c.cursor.agent_auth_path,
2375 Some(home.join("cursor-agent-auth.json"))
2376 );
2377 }
2378
2379 #[test]
2380 fn cursor_appears_when_enabled() {
2381 let f = write_toml(
2382 r#"
2383 [cursor]
2384 enabled = true
2385 "#,
2386 );
2387 let c = Config::load_from(f.path()).unwrap();
2388 assert!(c.is_enabled(VendorId::Cursor));
2389 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
2390 }
2391
2392 #[test]
2393 fn add_account_appends_and_preserves_existing() {
2394 let mut doc: toml_edit::DocumentMut = r#"
2395# keep me
2396[anthropic]
2397enabled = true
2398
2399[[anthropic.accounts]]
2400label = "personal"
2401credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
2402"#
2403 .parse()
2404 .unwrap();
2405 add_anthropic_account_to_doc(
2406 &mut doc,
2407 "work",
2408 "~/.config/ai-usagebar/accounts/work/.credentials.json",
2409 )
2410 .unwrap();
2411 let rendered = doc.to_string();
2412 assert!(rendered.contains("# keep me"), "comment must survive");
2413 let f = write_toml(&rendered);
2415 let c = Config::load_from(f.path()).unwrap();
2416 let labels: Vec<&str> = c
2417 .anthropic
2418 .accounts
2419 .iter()
2420 .map(|a| a.label.as_str())
2421 .collect();
2422 assert_eq!(labels, vec!["personal", "work"]);
2423 }
2424
2425 #[test]
2426 fn add_account_to_empty_doc_is_loadable() {
2427 let mut doc = toml_edit::DocumentMut::new();
2428 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
2429 let f = write_toml(&doc.to_string());
2430 let c = Config::load_from(f.path()).unwrap();
2431 assert_eq!(c.anthropic.accounts.len(), 1);
2432 assert_eq!(c.anthropic.accounts[0].label, "solo");
2433 }
2434
2435 #[test]
2436 fn add_account_rejects_duplicate_label() {
2437 let mut doc: toml_edit::DocumentMut = r#"
2438[[anthropic.accounts]]
2439label = "work"
2440credentials_path = "~/w/.credentials.json"
2441"#
2442 .parse()
2443 .unwrap();
2444 assert!(
2445 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
2446 "a duplicate label must be rejected, not appended"
2447 );
2448 }
2449
2450 #[test]
2451 fn add_account_rejects_bad_label() {
2452 let mut doc = toml_edit::DocumentMut::new();
2453 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
2454 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
2455 }
2456
2457 #[test]
2458 fn tildify_collapses_home_only() {
2459 let home = Path::new("/Users/me");
2460 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
2461 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
2462 }
2463
2464 #[test]
2465 fn default_account_credentials_path_nests_under_config_dir() {
2466 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
2467 assert_eq!(
2468 default_account_credentials_path(cfg, "work"),
2469 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
2470 );
2471 }
2472}