1use std::collections::{BTreeMap, HashSet};
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21
22use crate::anthropic::creds::CredsTarget;
23use crate::cache::Cache;
24use crate::error::{AppError, Result};
25use crate::vendor::VendorId;
26
27#[derive(Debug, Clone, Default, Deserialize, Serialize)]
34#[serde(default, deny_unknown_fields)]
35pub struct Config {
36 pub ui: UiConfig,
37 pub context: ContextConfig,
38 pub anthropic: AnthropicConfig,
39 pub anthropic_api: AnthropicApiConfig,
40 pub openai: OpenAiConfig,
41 pub zai: ZaiConfig,
42 pub openrouter: OpenRouterConfig,
43 pub deepseek: DeepseekConfig,
44 pub kimi: KimiConfig,
45 pub kilo: KiloConfig,
46 pub novita: NovitaConfig,
47 pub moonshot: MoonshotConfig,
48 pub grok: GrokConfig,
49 pub antigravity: AntigravityConfig,
50 pub cursor: CursorConfig,
51 pub minimax: MinimaxConfig,
52}
53
54#[derive(Debug, Clone, Default, Deserialize, Serialize)]
58#[serde(default)]
59pub struct UiConfig {
60 pub primary: Option<VendorId>,
62 pub overview_vendors: Option<Vec<VendorId>>,
66 pub vendor_box: Option<VendorBoxStyle>,
68}
69
70impl UiConfig {
71 pub fn vendor_box(&self) -> VendorBoxStyle {
72 self.vendor_box.unwrap_or_default()
73 }
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
78#[serde(rename_all = "lowercase")]
79pub enum VendorBoxStyle {
80 #[default]
82 Sidebar,
83 Navbar,
85 None,
87}
88
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
92#[serde(rename_all = "lowercase")]
93pub enum ContextLayout {
94 #[default]
96 Full,
97 Split,
99 Bottom,
101}
102
103impl ContextLayout {
104 pub fn next(self) -> Self {
105 match self {
106 ContextLayout::Full => ContextLayout::Split,
107 ContextLayout::Split => ContextLayout::Bottom,
108 ContextLayout::Bottom => ContextLayout::Full,
109 }
110 }
111
112 pub fn label(self) -> &'static str {
113 match self {
114 ContextLayout::Full => "full",
115 ContextLayout::Split => "split",
116 ContextLayout::Bottom => "bottom",
117 }
118 }
119}
120
121#[derive(Debug, Clone, Default, Deserialize, Serialize)]
126#[serde(default)]
127pub struct ContextConfig {
128 pub enabled: bool,
131 pub projects_path: Option<PathBuf>,
133 pub context_window_tokens: Option<u64>,
136 pub model_context_window_tokens: BTreeMap<String, u64>,
139 pub layout: ContextLayout,
141}
142
143impl ContextConfig {
144 pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
145 model
146 .and_then(|model| self.model_context_window_tokens.get(model).copied())
147 .filter(|tokens| *tokens > 0)
148 .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
149 }
150}
151
152#[derive(Debug, Clone, Deserialize, Serialize)]
153#[serde(default)]
154pub struct AnthropicConfig {
155 pub enabled: bool,
156 pub credentials_path: Option<PathBuf>,
159 pub accounts: Vec<AnthropicAccount>,
163 pub accounts_dir: Option<PathBuf>,
171 pub show_default_account: bool,
177 pub desktop_profiles_dir: Option<PathBuf>,
183}
184
185impl Default for AnthropicConfig {
186 fn default() -> Self {
187 Self {
188 enabled: true,
189 credentials_path: None,
190 accounts: Vec::new(),
191 accounts_dir: None,
192 show_default_account: true,
193 desktop_profiles_dir: None,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
208pub struct AnthropicAccount {
209 pub label: String,
212 pub credentials_path: PathBuf,
216}
217
218impl AnthropicAccount {
219 pub fn config_dir(&self) -> PathBuf {
224 self.credentials_path
225 .parent()
226 .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
227 }
228}
229
230impl AnthropicConfig {
231 pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
237 let mut out = self.accounts.clone();
238 if let Some(dir) = &self.accounts_dir {
239 for acct in discover_accounts(dir) {
240 if !out.iter().any(|a| a.label == acct.label) {
241 out.push(acct);
242 }
243 }
244 }
245 out
246 }
247
248 pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
253 validate_account_label(label)?;
254 let all = self.all_accounts();
255 all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
256 let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
257 AppError::Credentials(format!(
258 "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
259 known labels: {known:?}"
260 ))
261 })
262 }
263
264 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
275 let active = crate::anthropic::cli_account::home_claude_json()
276 .ok()
277 .and_then(|path| {
278 crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
279 });
280 self.account_target_with(label, active.as_deref())
281 }
282
283 pub fn account_target_with(
294 &self,
295 label: &str,
296 cli_active: Option<&str>,
297 ) -> Result<(CredsTarget, Cache)> {
298 let account = self.account(label)?;
299 let cache = Cache::for_vendor_account("anthropic", label)?;
300 if cli_active == Some(label) {
301 return Ok((
302 CredsTarget::Default(crate::anthropic::creds::default_path()?),
303 cache,
304 ));
305 }
306 Ok((
307 CredsTarget::Named {
308 config_dir: account.config_dir(),
309 path: account.credentials_path,
310 },
311 cache,
312 ))
313 }
314}
315
316pub fn validate_account_label(label: &str) -> Result<()> {
322 const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
323 let bad = label.is_empty()
324 || label == "."
325 || label == ".."
326 || label.contains(['/', '\\'])
327 || label.chars().any(char::is_control)
328 || RESERVED.contains(&label);
329 if bad {
330 return Err(AppError::Credentials(format!(
331 "invalid anthropic account label {label:?}: must be a non-empty name \
332 without path separators, control characters, or reserved cache names"
333 )));
334 }
335 Ok(())
336}
337
338fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
346 let Ok(entries) = std::fs::read_dir(accounts_dir) else {
347 return Vec::new();
348 };
349 let mut found: Vec<AnthropicAccount> = entries
350 .flatten()
351 .filter_map(|entry| {
352 let path = entry.path();
353 if !path.is_dir() {
354 return None;
355 }
356 let label = path.file_name()?.to_str()?.to_string();
357 validate_account_label(&label).ok()?;
358 Some(AnthropicAccount {
359 label,
360 credentials_path: path.join(".credentials.json"),
361 })
362 })
363 .collect();
364 found.sort_by(|a, b| a.label.cmp(&b.label));
365 found
366}
367
368pub fn tildify(path: &Path, home: &Path) -> String {
372 path.strip_prefix(home)
373 .map(|rest| {
374 let rendered = rest.display().to_string();
375 #[cfg(windows)]
378 let rendered = rendered.replace('\\', "/");
379 format!("~/{rendered}")
380 })
381 .unwrap_or_else(|_| path.display().to_string())
382}
383
384pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
389 let base = config_path.parent().unwrap_or_else(|| Path::new("."));
390 base.join("accounts").join(label).join(".credentials.json")
391}
392
393pub fn add_anthropic_account_to_doc(
399 doc: &mut toml_edit::DocumentMut,
400 label: &str,
401 credentials_path: &str,
402) -> Result<()> {
403 use toml_edit::{Item, Table, value};
404
405 validate_account_label(label)?;
406
407 let anthropic = doc
408 .entry("anthropic")
409 .or_insert_with(|| Item::Table(Table::new()));
410 let anthropic = anthropic
411 .as_table_mut()
412 .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
413
414 let accounts = anthropic
415 .entry("accounts")
416 .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
417 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
418 AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
419 })?;
420
421 let exists = accounts
422 .iter()
423 .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
424 if exists {
425 return Err(AppError::Credentials(format!(
426 "anthropic account {label:?} already exists in config.toml"
427 )));
428 }
429
430 let mut table = Table::new();
431 table["label"] = value(label);
432 table["credentials_path"] = value(credentials_path);
433 accounts.push(table);
434 Ok(())
435}
436
437#[derive(Debug, Clone, Deserialize, Serialize)]
438#[serde(default)]
439pub struct OpenAiConfig {
440 pub enabled: bool,
441 pub codex_auth_path: Option<PathBuf>,
443 pub admin_key_env: String,
451}
452
453impl Default for OpenAiConfig {
454 fn default() -> Self {
455 Self {
456 enabled: true,
457 codex_auth_path: None,
458 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
459 }
460 }
461}
462
463#[derive(Debug, Clone, Deserialize, Serialize)]
464#[serde(default)]
465pub struct ZaiConfig {
466 pub enabled: bool,
467 pub api_key_env: String,
469 pub api_key: Option<String>,
472 pub plan_tier: Option<String>,
474}
475
476impl Default for ZaiConfig {
477 fn default() -> Self {
478 Self {
479 enabled: true,
480 api_key_env: "ZAI_API_KEY".to_string(),
481 api_key: None,
482 plan_tier: None,
483 }
484 }
485}
486
487#[derive(Debug, Clone, Deserialize, Serialize)]
488#[serde(default)]
489pub struct OpenRouterConfig {
490 pub enabled: bool,
491 pub api_key_env: String,
492 pub api_key: Option<String>,
493}
494
495impl Default for OpenRouterConfig {
496 fn default() -> Self {
497 Self {
498 enabled: true,
499 api_key_env: "OPENROUTER_API_KEY".to_string(),
500 api_key: None,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Deserialize, Serialize)]
506#[serde(default)]
507pub struct DeepseekConfig {
508 pub enabled: bool,
509 pub api_key_env: String,
510 pub api_key: Option<String>,
511}
512
513impl Default for DeepseekConfig {
514 fn default() -> Self {
515 Self {
516 enabled: false,
517 api_key_env: "DEEPSEEK_API_KEY".to_string(),
518 api_key: None,
519 }
520 }
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
524#[serde(default)]
525pub struct KimiConfig {
526 pub enabled: bool,
527 pub api_key_env: String,
528 pub api_key: Option<String>,
529}
530
531impl Default for KimiConfig {
532 fn default() -> Self {
533 Self {
534 enabled: false,
535 api_key_env: "KIMI_API_KEY".to_string(),
536 api_key: None,
537 }
538 }
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize)]
542#[serde(default)]
543pub struct KiloConfig {
544 pub enabled: bool,
545 pub api_key_env: String,
546 pub api_key: Option<String>,
547 pub organization_id: Option<String>,
550}
551
552impl Default for KiloConfig {
553 fn default() -> Self {
554 Self {
557 enabled: false,
558 api_key_env: "KILO_API_KEY".to_string(),
559 api_key: None,
560 organization_id: None,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Deserialize, Serialize)]
566#[serde(default)]
567pub struct NovitaConfig {
568 pub enabled: bool,
569 pub api_key_env: String,
570 pub api_key: Option<String>,
571}
572
573impl Default for NovitaConfig {
574 fn default() -> Self {
575 Self {
577 enabled: false,
578 api_key_env: "NOVITA_API_KEY".to_string(),
579 api_key: None,
580 }
581 }
582}
583
584#[derive(Debug, Clone, Deserialize, Serialize)]
585#[serde(default)]
586pub struct MinimaxConfig {
587 pub enabled: bool,
588 pub api_key_env: String,
589 pub api_key: Option<String>,
590 pub region: String,
596}
597
598impl Default for MinimaxConfig {
599 fn default() -> Self {
600 Self {
602 enabled: false,
603 api_key_env: "MINIMAX_API_KEY".to_string(),
604 api_key: None,
605 region: "global".to_string(),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Deserialize, Serialize)]
611#[serde(default)]
612pub struct MoonshotConfig {
613 pub enabled: bool,
614 pub api_key_env: String,
615 pub api_key: Option<String>,
616 pub region: String,
618}
619
620impl Default for MoonshotConfig {
621 fn default() -> Self {
622 Self {
624 enabled: false,
625 api_key_env: "MOONSHOT_API_KEY".to_string(),
626 api_key: None,
627 region: "global".to_string(),
628 }
629 }
630}
631
632#[derive(Debug, Clone, Deserialize, Serialize)]
633#[serde(default)]
634pub struct GrokConfig {
635 pub enabled: bool,
636 pub api_key_env: String,
638 pub api_key: Option<String>,
639 pub team_id: Option<String>,
642}
643
644impl Default for GrokConfig {
645 fn default() -> Self {
646 Self {
648 enabled: false,
649 api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
650 api_key: None,
651 team_id: None,
652 }
653 }
654}
655
656#[derive(Debug, Clone, Default, Deserialize, Serialize)]
659#[serde(default)]
660pub struct AntigravityConfig {
661 pub enabled: bool,
662}
663
664#[derive(Debug, Clone, Default, Deserialize, Serialize)]
674#[serde(default)]
675pub struct CursorConfig {
676 pub enabled: bool,
677 pub db_path: Option<PathBuf>,
681}
682
683#[derive(Debug, Clone, Deserialize, Serialize)]
684#[serde(default)]
685pub struct AnthropicApiConfig {
686 pub enabled: bool,
687 pub api_key_env: String,
690 pub api_key: Option<String>,
691 pub monthly_limit: Option<f64>,
694}
695
696impl Default for AnthropicApiConfig {
697 fn default() -> Self {
698 Self {
700 enabled: false,
701 api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
702 api_key: None,
703 monthly_limit: None,
704 }
705 }
706}
707
708pub fn resolve_api_key(
711 vendor_label: &str,
712 env_var_name: &str,
713 inline: Option<&str>,
714) -> crate::error::Result<String> {
715 let valid_env_name = is_valid_env_var_name(env_var_name);
716 if valid_env_name
717 && let Ok(v) = std::env::var(env_var_name)
718 && !v.is_empty()
719 {
720 return Ok(v);
721 }
722 if let Some(v) = inline
723 && !v.is_empty()
724 {
725 return Ok(v.to_string());
726 }
727 let advice = if valid_env_name {
728 "set an API key in a valid environment variable or set `api_key`"
729 } else {
730 "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
731 };
732 Err(crate::error::AppError::Credentials(format!(
733 "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
734 vendor_label.to_lowercase(),
735 config_path_hint()
736 )))
737}
738
739fn is_valid_env_var_name(name: &str) -> bool {
740 let mut chars = name.chars();
741 let Some(first) = chars.next() else {
742 return false;
743 };
744 (first.is_ascii_alphabetic() || first == '_')
745 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
746}
747
748impl Config {
749 pub fn load() -> Result<Self> {
752 let Some(path) = resolved_path() else {
753 return Ok(Self::default());
754 };
755 Self::load_from(&path)
756 }
757
758 pub fn load_from(path: &std::path::Path) -> Result<Self> {
759 match std::fs::read_to_string(path) {
760 Ok(s) => {
761 let mut config: Self = toml::from_str(&s)?;
762 config.expand_paths();
766 config.validate()?;
767 Ok(config)
768 }
769 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
770 Err(e) => Err(AppError::io_at(path, e)),
771 }
772 }
773
774 fn expand_paths(&mut self) {
775 expand_tilde_opt(&mut self.context.projects_path);
776 expand_tilde_opt(&mut self.anthropic.credentials_path);
777 expand_tilde_opt(&mut self.anthropic.accounts_dir);
778 expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
779 expand_tilde_opt(&mut self.openai.codex_auth_path);
780 expand_tilde_opt(&mut self.cursor.db_path);
781 for account in &mut self.anthropic.accounts {
782 account.credentials_path = expand_tilde(&account.credentials_path);
783 }
784 }
785
786 pub fn is_enabled(&self, id: VendorId) -> bool {
787 match id {
788 VendorId::Anthropic => self.anthropic.enabled,
789 VendorId::AnthropicApi => self.anthropic_api.enabled,
790 VendorId::Openai => self.openai.enabled,
791 VendorId::Zai => self.zai.enabled,
792 VendorId::Openrouter => self.openrouter.enabled,
793 VendorId::Deepseek => self.deepseek.enabled,
794 VendorId::Kimi => self.kimi.enabled,
795 VendorId::Kilo => self.kilo.enabled,
796 VendorId::Novita => self.novita.enabled,
797 VendorId::Moonshot => self.moonshot.enabled,
798 VendorId::Grok => self.grok.enabled,
799 VendorId::Antigravity => self.antigravity.enabled,
800 VendorId::Cursor => self.cursor.enabled,
801 VendorId::Minimax => self.minimax.enabled,
802 }
803 }
804
805 pub fn enabled_vendors(&self) -> Vec<VendorId> {
806 VendorId::all()
807 .iter()
808 .copied()
809 .filter(|id| self.is_enabled(*id))
810 .collect()
811 }
812
813 pub fn validate(&self) -> Result<()> {
817 if self.context.context_window_tokens == Some(0) {
818 return Err(AppError::Other(
819 "[context] context_window_tokens must be greater than zero".into(),
820 ));
821 }
822 for (model, tokens) in &self.context.model_context_window_tokens {
823 if model.trim().is_empty() {
824 return Err(AppError::Other(
825 "[context] model_context_window_tokens keys must not be empty".into(),
826 ));
827 }
828 if *tokens == 0 {
829 return Err(AppError::Other(format!(
830 "[context] model_context_window_tokens entry {model:?} must be greater than zero"
831 )));
832 }
833 }
834 if let Some(limit) = self.anthropic_api.monthly_limit
835 && (!limit.is_finite() || limit <= 0.0)
836 {
837 return Err(AppError::Other(
838 "[anthropic_api] monthly_limit must be finite and greater than zero; \
839 remove it to show spend without a limit"
840 .into(),
841 ));
842 }
843 if !self.minimax.region.eq_ignore_ascii_case("global")
844 && !self.minimax.region.eq_ignore_ascii_case("cn")
845 {
846 return Err(AppError::Other(format!(
847 "[minimax] region must be \"global\" or \"cn\", got {:?}",
848 self.minimax.region
849 )));
850 }
851 let mut labels = HashSet::new();
852 for account in &self.anthropic.accounts {
853 validate_account_label(&account.label)?;
854 if !labels.insert(&account.label) {
855 return Err(AppError::Credentials(format!(
856 "duplicate anthropic account label {:?}",
857 account.label
858 )));
859 }
860 }
861 Ok(())
862 }
863}
864
865pub fn default_path() -> Option<PathBuf> {
866 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
867 Some(proj.config_dir().join("config.toml"))
868}
869
870fn legacy_xdg_path() -> Option<PathBuf> {
875 let home = crate::cache::home_dir().ok()?;
876 Some(home.join(".config").join("ai-usagebar").join("config.toml"))
877}
878
879pub fn resolved_path() -> Option<PathBuf> {
888 let canonical = default_path();
889 if let Some(p) = &canonical
890 && p.exists()
891 {
892 return canonical;
893 }
894 if let Some(legacy) = legacy_xdg_path()
895 && legacy.exists()
896 {
897 return Some(legacy);
898 }
899 canonical
900}
901
902fn expand_tilde(p: &std::path::Path) -> PathBuf {
905 let Some(s) = p.to_str() else {
906 return p.to_path_buf();
907 };
908 let rest = if s == "~" {
909 ""
910 } else if let Some(r) = s.strip_prefix("~/") {
911 r
912 } else {
913 return p.to_path_buf();
914 };
915 match crate::cache::home_dir() {
916 Ok(home) if rest.is_empty() => home,
917 Ok(home) => home.join(rest),
918 Err(_) => p.to_path_buf(),
919 }
920}
921
922fn expand_tilde_opt(p: &mut Option<PathBuf>) {
923 if let Some(inner) = p.as_ref() {
924 *p = Some(expand_tilde(inner));
925 }
926}
927
928pub fn config_path_hint() -> String {
933 resolved_path()
934 .map(|p| p.display().to_string())
935 .unwrap_or_else(|| "config.toml".to_string())
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use std::io::Write;
942 use tempfile::NamedTempFile;
943
944 fn write_toml(s: &str) -> NamedTempFile {
945 let mut f = NamedTempFile::new().unwrap();
946 f.write_all(s.as_bytes()).unwrap();
947 f.flush().unwrap();
948 f
949 }
950
951 #[test]
952 fn defaults_enable_only_the_four_core_vendors() {
953 let c = Config::default();
954 assert!(c.is_enabled(VendorId::Anthropic));
955 assert!(c.is_enabled(VendorId::Openai));
956 assert!(c.is_enabled(VendorId::Zai));
957 assert!(c.is_enabled(VendorId::Openrouter));
958 for opt_in in [
959 VendorId::AnthropicApi,
960 VendorId::Deepseek,
961 VendorId::Kimi,
962 VendorId::Kilo,
963 VendorId::Novita,
964 VendorId::Moonshot,
965 VendorId::Grok,
966 VendorId::Cursor,
967 VendorId::Minimax,
968 ] {
969 assert!(!c.is_enabled(opt_in), "{opt_in:?}");
970 }
971 assert_eq!(c.enabled_vendors().len(), 4);
972 }
973
974 #[test]
975 fn missing_file_uses_defaults() {
976 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
977 let c = Config::load_from(path).unwrap();
978 assert!(c.is_enabled(VendorId::Anthropic));
979 }
980
981 #[test]
982 fn parses_full_config() {
983 let f = write_toml(
984 r#"
985 [anthropic]
986 enabled = true
987
988 [openai]
989 enabled = false
990 admin_key_env = "MY_ADMIN_KEY"
991
992 [zai]
993 enabled = true
994 api_key_env = "MY_ZAI"
995 plan_tier = "pro"
996
997 [openrouter]
998 enabled = false
999 "#,
1000 );
1001 let c = Config::load_from(f.path()).unwrap();
1002 assert!(c.is_enabled(VendorId::Anthropic));
1003 assert!(!c.is_enabled(VendorId::Openai));
1004 assert!(c.is_enabled(VendorId::Zai));
1005 assert!(!c.is_enabled(VendorId::Openrouter));
1006 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
1007 assert_eq!(c.zai.api_key_env, "MY_ZAI");
1008 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
1009 }
1010
1011 #[test]
1012 fn partial_config_falls_back_to_defaults() {
1013 let f = write_toml(
1014 r#"[openai]
1015enabled = false
1016"#,
1017 );
1018 let c = Config::load_from(f.path()).unwrap();
1019 assert!(!c.is_enabled(VendorId::Openai));
1020 assert!(c.is_enabled(VendorId::Anthropic));
1022 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
1023 }
1024
1025 #[test]
1026 fn malformed_toml_returns_error() {
1027 let f = write_toml("this is not = = valid");
1028 assert!(Config::load_from(f.path()).is_err());
1029 }
1030
1031 #[test]
1032 fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
1033 for value in ["0", "-1", "inf", "nan"] {
1034 let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
1035 let error = Config::load_from(file.path()).unwrap_err().to_string();
1036 assert!(error.contains("monthly_limit"), "value {value}: {error}");
1037 }
1038
1039 let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
1040 assert_eq!(
1041 Config::load_from(file.path())
1042 .unwrap()
1043 .anthropic_api
1044 .monthly_limit,
1045 Some(1000.0)
1046 );
1047 }
1048
1049 #[test]
1050 fn minimax_region_accepts_only_known_instances() {
1051 for region in ["global", "GLOBAL", "cn", "CN"] {
1052 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1053 assert_eq!(
1054 Config::load_from(file.path()).unwrap().minimax.region,
1055 region
1056 );
1057 }
1058
1059 for region in ["", "china", "us"] {
1060 let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
1061 let error = Config::load_from(file.path()).unwrap_err().to_string();
1062 assert!(error.contains("[minimax] region"), "{error}");
1063 }
1064 }
1065
1066 #[test]
1067 fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
1068 let defaults = Config::default();
1069 assert!(!defaults.context.enabled);
1070 assert_eq!(
1071 defaults.context.window_tokens_for(Some("claude-test")),
1072 None
1073 );
1074
1075 let file = write_toml(
1076 r#"
1077 [context]
1078 enabled = true
1079 context_window_tokens = 200000
1080
1081 [context.model_context_window_tokens]
1082 claude-opus-1m = 1000000
1083 "claude exact id" = 300000
1084 "#,
1085 );
1086 let config = Config::load_from(file.path()).unwrap();
1087 assert!(config.context.enabled);
1088 assert_eq!(
1089 config.context.window_tokens_for(Some("claude-opus-1m")),
1090 Some(1_000_000)
1091 );
1092 assert_eq!(
1093 config.context.window_tokens_for(Some("claude exact id")),
1094 Some(300_000)
1095 );
1096 assert_eq!(
1097 config.context.window_tokens_for(Some("another-model")),
1098 Some(200_000)
1099 );
1100 }
1101
1102 #[test]
1103 fn context_layout_defaults_to_full_and_parses_each_variant() {
1104 assert_eq!(Config::default().context.layout, ContextLayout::Full);
1105 for (text, want) in [
1106 ("full", ContextLayout::Full),
1107 ("split", ContextLayout::Split),
1108 ("bottom", ContextLayout::Bottom),
1109 ] {
1110 let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
1111 assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
1112 }
1113 let file = write_toml("[context]\nlayout = \"floating\"\n");
1114 assert!(
1115 Config::load_from(file.path()).is_err(),
1116 "an unknown layout must be rejected, not silently defaulted"
1117 );
1118 }
1119
1120 #[test]
1121 fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
1122 assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
1123 for (text, want) in [
1124 ("sidebar", VendorBoxStyle::Sidebar),
1125 ("navbar", VendorBoxStyle::Navbar),
1126 ("none", VendorBoxStyle::None),
1127 ] {
1128 let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
1129 assert_eq!(
1130 Config::load_from(file.path()).unwrap().ui.vendor_box(),
1131 want
1132 );
1133 }
1134 let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
1135 assert!(
1136 Config::load_from(file.path()).is_err(),
1137 "an unknown vendor_box style must be rejected, not silently defaulted"
1138 );
1139 }
1140
1141 #[test]
1142 fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
1143 for source in [
1144 "[context]\ncontext_window_tokens = 0\n",
1145 "[context.model_context_window_tokens]\nclaude = 0\n",
1146 "[context.model_context_window_tokens]\n\" \" = 200000\n",
1147 ] {
1148 let file = write_toml(source);
1149 let error = Config::load_from(file.path()).unwrap_err().to_string();
1150 assert!(error.contains("context"), "{error}");
1151 }
1152 }
1153
1154 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
1156 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
1157 M.lock().unwrap_or_else(|p| p.into_inner())
1158 }
1159
1160 #[test]
1161 fn resolve_api_key_prefers_env_over_inline() {
1162 let _g = env_guard();
1163 let var = "AI_USAGEBAR_TEST_ENV_WINS";
1165 unsafe { std::env::set_var(var, "from-env") };
1167 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
1168 unsafe { std::env::remove_var(var) };
1169 assert_eq!(got, "from-env");
1170 }
1171
1172 #[test]
1173 fn resolve_api_key_falls_back_to_inline() {
1174 let _g = env_guard();
1175 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
1176 unsafe { std::env::remove_var(var) };
1177 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
1178 assert_eq!(got, "inline-key");
1179 }
1180
1181 #[test]
1182 fn resolve_api_key_errors_when_both_missing() {
1183 let _g = env_guard();
1184 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
1185 unsafe { std::env::remove_var(var) };
1186 let err = resolve_api_key("Zai", var, None).unwrap_err();
1187 match err {
1188 crate::error::AppError::Credentials(msg) => {
1189 assert!(
1190 msg.contains("api_key"),
1191 "error should suggest config field: {msg}"
1192 );
1193 }
1194 other => panic!("expected Credentials error, got {other:?}"),
1195 }
1196 }
1197
1198 #[test]
1199 fn config_path_hint_ends_with_config_toml() {
1200 assert!(config_path_hint().ends_with("config.toml"));
1203 }
1204
1205 #[test]
1206 fn resolve_api_key_treats_empty_env_as_unset() {
1207 let _g = env_guard();
1208 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
1209 unsafe { std::env::set_var(var, "") };
1210 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
1211 unsafe { std::env::remove_var(var) };
1212 assert_eq!(got, "inline");
1213 }
1214
1215 #[test]
1216 fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
1217 let _g = env_guard();
1218 let bad = "sk-kimi-very-real-looking-pasted-secret";
1220 let err = resolve_api_key("Kimi", bad, None).unwrap_err();
1221 let msg = err.to_string();
1222 assert!(
1223 msg.contains("invalid") && msg.contains("api_key_env"),
1224 "error should explain misconfiguration: {msg}"
1225 );
1226 assert!(
1227 !msg.contains(bad),
1228 "error must not echo the misconfigured value: {msg}"
1229 );
1230 assert!(msg.contains("valid environment variable name"));
1231 assert!(
1232 msg.contains("[kimi]"),
1233 "error should point at the lowercase TOML section: {msg}"
1234 );
1235 }
1236
1237 #[test]
1238 fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
1239 let _g = env_guard();
1240 let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
1241 assert_eq!(got, "inline-key");
1242 }
1243
1244 #[test]
1245 fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
1246 let _g = env_guard();
1247 let pasted_secret = "sk_pasted_secret";
1250 unsafe { std::env::remove_var(pasted_secret) };
1251 let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
1252 assert!(
1253 !err.to_string().contains(pasted_secret),
1254 "error must not echo configured api_key_env values"
1255 );
1256 }
1257
1258 #[test]
1259 fn is_valid_env_var_name_rules() {
1260 for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
1262 assert!(is_valid_env_var_name(valid), "{valid} should be valid");
1263 }
1264 for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
1266 assert!(
1267 !is_valid_env_var_name(invalid),
1268 "{invalid} should be invalid"
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 fn config_parses_with_inline_api_key_and_primary() {
1275 let f = write_toml(
1276 r#"
1277 [ui]
1278 primary = "openrouter"
1279
1280 [zai]
1281 enabled = true
1282 api_key_env = "MY_ZAI"
1283 api_key = "sk-zai-inline"
1284
1285 [openrouter]
1286 enabled = true
1287 api_key = "sk-or-inline"
1288 "#,
1289 );
1290 let c = Config::load_from(f.path()).unwrap();
1291 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
1292 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
1293 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
1294 }
1295
1296 #[test]
1297 fn enabled_vendors_preserves_canonical_order() {
1298 let c = Config::default();
1301 assert_eq!(
1302 c.enabled_vendors(),
1303 vec![
1304 VendorId::Anthropic,
1305 VendorId::Openai,
1306 VendorId::Zai,
1307 VendorId::Openrouter,
1308 ]
1309 );
1310 }
1311
1312 #[test]
1313 fn deepseek_appears_when_enabled() {
1314 let f = write_toml(
1315 r#"
1316 [deepseek]
1317 enabled = true
1318 api_key = "sk-test"
1319 "#,
1320 );
1321 let c = Config::load_from(f.path()).unwrap();
1322 assert!(c.is_enabled(VendorId::Deepseek));
1323 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
1324 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
1325 }
1326
1327 #[test]
1328 fn tilde_paths_are_expanded_on_load() {
1329 let f = write_toml(
1333 r#"
1334 [context]
1335 projects_path = "~/.claude/projects"
1336
1337 [anthropic]
1338 credentials_path = "~/.claude/.credentials.json"
1339
1340 [[anthropic.accounts]]
1341 label = "work"
1342 credentials_path = "~/work.json"
1343 "#,
1344 );
1345 let c = Config::load_from(f.path()).unwrap();
1346 let home = crate::cache::home_dir().unwrap();
1347
1348 assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
1349 let got = c.anthropic.credentials_path.unwrap();
1350 assert_eq!(got, home.join(".claude/.credentials.json"));
1351 assert!(!got.to_string_lossy().contains('~'));
1352 assert_eq!(
1353 c.anthropic.accounts[0].credentials_path,
1354 home.join("work.json")
1355 );
1356 }
1357
1358 #[test]
1359 fn absolute_and_relative_paths_are_left_alone() {
1360 let f = write_toml(
1361 r#"
1362 [anthropic]
1363 credentials_path = "/etc/creds.json"
1364 "#,
1365 );
1366 let c = Config::load_from(f.path()).unwrap();
1367 assert_eq!(
1368 c.anthropic.credentials_path.unwrap(),
1369 std::path::Path::new("/etc/creds.json")
1370 );
1371
1372 let f2 = write_toml(
1374 r#"
1375 [anthropic]
1376 credentials_path = "~someone/creds.json"
1377 "#,
1378 );
1379 let c2 = Config::load_from(f2.path()).unwrap();
1380 assert_eq!(
1381 c2.anthropic.credentials_path.unwrap(),
1382 std::path::Path::new("~someone/creds.json")
1383 );
1384 }
1385
1386 #[test]
1387 fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
1388 let p = resolved_path().expect("a config path must resolve");
1391 assert!(p.ends_with("config.toml"));
1392 let canonical = default_path().unwrap();
1393 let legacy = legacy_xdg_path().unwrap();
1394 assert!(
1395 p == canonical || p == legacy,
1396 "resolved to an unexpected location: {}",
1397 p.display()
1398 );
1399 }
1400
1401 #[test]
1402 fn misspelled_section_is_rejected_not_ignored() {
1403 let f = write_toml(
1406 r#"
1407 [openrouer]
1408 enabled = true
1409 api_key = "sk-or-v1-typo"
1410 "#,
1411 );
1412 let err = Config::load_from(f.path()).unwrap_err().to_string();
1413 assert!(
1414 err.contains("openrouer"),
1415 "error should name the typo: {err}"
1416 );
1417 }
1418
1419 #[test]
1420 fn invalid_toml_is_an_error_not_silent_defaults() {
1421 let f = write_toml("[zai\nenabled = true\n");
1422 assert!(Config::load_from(f.path()).is_err());
1423 }
1424
1425 #[test]
1426 fn a_missing_file_is_still_just_defaults() {
1427 let dir = tempfile::tempdir().unwrap();
1430 let missing = dir.path().join("nope").join("config.toml");
1431 let c = Config::load_from(&missing).unwrap();
1432 assert!(c.is_enabled(VendorId::Anthropic));
1433 }
1434
1435 #[test]
1436 fn kimi_appears_when_enabled() {
1437 let f = write_toml(
1438 r#"
1439 [kimi]
1440 enabled = true
1441 api_key = "sk-test"
1442 "#,
1443 );
1444 let c = Config::load_from(f.path()).unwrap();
1445 assert!(c.is_enabled(VendorId::Kimi));
1446 assert!(c.enabled_vendors().contains(&VendorId::Kimi));
1447 assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
1448 }
1449
1450 #[test]
1451 fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
1452 let f = write_toml(
1453 r#"
1454 [deepseek]
1455 enabled = true
1456 api_key = "sk-ds"
1457
1458 [kimi]
1459 enabled = true
1460 api_key = "sk-kimi"
1461 "#,
1462 );
1463 let c = Config::load_from(f.path()).unwrap();
1464 assert_eq!(
1465 c.enabled_vendors(),
1466 vec![
1467 VendorId::Anthropic,
1468 VendorId::Openai,
1469 VendorId::Zai,
1470 VendorId::Openrouter,
1471 VendorId::Deepseek,
1472 VendorId::Kimi,
1473 ]
1474 );
1475 }
1476
1477 #[test]
1478 fn parses_anthropic_accounts_and_looks_them_up() {
1479 let f = write_toml(
1480 r#"
1481 [anthropic]
1482 enabled = true
1483
1484 [[anthropic.accounts]]
1485 label = "personal"
1486 credentials_path = "/creds/personal.json"
1487
1488 [[anthropic.accounts]]
1489 label = "work"
1490 credentials_path = "/creds/work.json"
1491 "#,
1492 );
1493 let c = Config::load_from(f.path()).unwrap();
1494 assert_eq!(c.anthropic.accounts.len(), 2);
1495 let work = c.anthropic.account("work").unwrap();
1496 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
1497 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
1499 assert!(err.contains("missing") && err.contains("work"), "{err}");
1500 }
1501
1502 #[test]
1503 fn duplicate_anthropic_account_labels_are_rejected_on_load() {
1504 let f = write_toml(
1505 r#"
1506 [[anthropic.accounts]]
1507 label = "work"
1508 credentials_path = "/creds/work-one.json"
1509
1510 [[anthropic.accounts]]
1511 label = "work"
1512 credentials_path = "/creds/work-two.json"
1513 "#,
1514 );
1515 let err = Config::load_from(f.path()).unwrap_err().to_string();
1516 assert!(
1517 err.contains("duplicate anthropic account label \"work\""),
1518 "{err}"
1519 );
1520 }
1521
1522 #[test]
1523 fn account_label_rejects_path_like_names() {
1524 let cfg = AnthropicConfig::default();
1525 for bad in [
1526 "",
1527 ".",
1528 "..",
1529 "a/b",
1530 r"a\b",
1531 "line\nbreak",
1532 "tab\tname",
1533 "usage.json",
1534 ".stale",
1535 ".last_error",
1536 ".fetch.lock",
1537 ] {
1538 let err = cfg.account(bad).unwrap_err();
1539 assert!(
1540 format!("{err:?}").contains("invalid anthropic account label"),
1541 "{bad:?} should be rejected as a label"
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn anthropic_accounts_default_to_empty() {
1548 assert!(Config::default().anthropic.accounts.is_empty());
1551 assert!(Config::default().anthropic.accounts_dir.is_none());
1552 }
1553
1554 fn seed_account_dir(root: &std::path::Path, label: &str) {
1560 let dir = root.join(label);
1561 std::fs::create_dir_all(&dir).unwrap();
1562 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1563 }
1564
1565 #[test]
1566 fn discovers_account_dirs_in_claude_config_dir_layout() {
1567 let td = tempfile::tempdir().unwrap();
1568 seed_account_dir(td.path(), "work");
1569 seed_account_dir(td.path(), "personal");
1570 std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
1573 std::fs::write(td.path().join("stray.json"), "{}").unwrap();
1575
1576 let cfg = AnthropicConfig {
1577 accounts_dir: Some(td.path().to_path_buf()),
1578 ..Default::default()
1579 };
1580 let all = cfg.all_accounts();
1581 let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
1582 assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
1583 assert_eq!(
1584 all[2].credentials_path,
1585 td.path().join("work").join(".credentials.json")
1586 );
1587 }
1588
1589 #[test]
1590 fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
1591 let td = tempfile::tempdir().unwrap();
1592 seed_account_dir(td.path(), "work");
1593 let cfg = AnthropicConfig {
1594 accounts: vec![AnthropicAccount {
1595 label: "work".into(),
1596 credentials_path: "/explicit/work.json".into(),
1597 }],
1598 accounts_dir: Some(td.path().to_path_buf()),
1599 ..Default::default()
1600 };
1601 let all = cfg.all_accounts();
1602 assert_eq!(all.len(), 1, "no duplicate label");
1603 assert_eq!(
1604 all[0].credentials_path,
1605 std::path::Path::new("/explicit/work.json"),
1606 "explicit entry wins"
1607 );
1608 seed_account_dir(td.path(), "other");
1610 assert_eq!(cfg.account("other").unwrap().label, "other");
1611 }
1612
1613 #[test]
1614 fn missing_accounts_dir_is_silently_empty_not_an_error() {
1615 let cfg = AnthropicConfig {
1616 accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
1617 ..Default::default()
1618 };
1619 assert!(cfg.all_accounts().is_empty());
1620 }
1621
1622 #[test]
1623 fn accounts_dir_is_tilde_expanded_on_load() {
1624 let f = write_toml(
1625 r#"
1626 [anthropic]
1627 accounts_dir = "~/.config/ai-usagebar/accounts"
1628 "#,
1629 );
1630 let c = Config::load_from(f.path()).unwrap();
1631 let home = crate::cache::home_dir().unwrap();
1632 assert_eq!(
1633 c.anthropic.accounts_dir,
1634 Some(home.join(".config/ai-usagebar/accounts"))
1635 );
1636 }
1637
1638 #[test]
1639 fn desktop_profiles_dir_is_tilde_expanded_on_load() {
1640 let f = write_toml(
1641 r#"
1642 [anthropic]
1643 desktop_profiles_dir = "~/.claude-acc/profiles"
1644 "#,
1645 );
1646 let c = Config::load_from(f.path()).unwrap();
1647 let home = crate::cache::home_dir().unwrap();
1648 assert_eq!(
1649 c.anthropic.desktop_profiles_dir,
1650 Some(home.join(".claude-acc/profiles"))
1651 );
1652 }
1653
1654 #[test]
1655 fn the_live_cli_account_is_read_from_the_default_credential_slot() {
1656 let cfg = AnthropicConfig {
1657 accounts: vec![
1658 AnthropicAccount {
1659 label: "work".into(),
1660 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1661 },
1662 AnthropicAccount {
1663 label: "personal".into(),
1664 credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
1665 },
1666 ],
1667 ..Default::default()
1668 };
1669
1670 let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
1671 assert!(
1672 matches!(&idle, CredsTarget::Named { config_dir, .. }
1673 if config_dir == std::path::Path::new("/tmp/accounts/work")),
1674 "{idle:?}"
1675 );
1676
1677 let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
1679 assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
1680
1681 assert_eq!(idle_cache.dir(), live_cache.dir());
1684 }
1685
1686 #[test]
1687 fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
1688 let cfg = AnthropicConfig {
1689 accounts: vec![AnthropicAccount {
1690 label: "work".into(),
1691 credentials_path: "/tmp/accounts/work/.credentials.json".into(),
1692 }],
1693 ..Default::default()
1694 };
1695 let (target, _) = cfg.account_target_with("work", None).unwrap();
1696 assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
1697 }
1698
1699 fn config_example() -> PathBuf {
1703 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
1704 }
1705
1706 #[test]
1707 fn shipped_example_parses_as_a_real_config() {
1708 let c = Config::load_from(&config_example()).unwrap();
1713 assert!(!c.context.enabled);
1714 assert!(c.is_enabled(VendorId::Anthropic));
1715 assert!(c.is_enabled(VendorId::Openai));
1716 assert!(!c.is_enabled(VendorId::AnthropicApi));
1717 assert!(!c.is_enabled(VendorId::Deepseek));
1718 assert!(!c.is_enabled(VendorId::Kimi));
1719 assert!(!c.is_enabled(VendorId::Kilo));
1720 assert!(!c.is_enabled(VendorId::Novita));
1721 assert!(!c.is_enabled(VendorId::Moonshot));
1722 assert!(!c.is_enabled(VendorId::Grok));
1723 assert!(!c.is_enabled(VendorId::Cursor));
1724 assert!(!c.is_enabled(VendorId::Minimax));
1725 }
1726
1727 #[test]
1728 fn shipped_example_does_not_advertise_admin_key_env_as_working() {
1729 let text = std::fs::read_to_string(config_example()).unwrap();
1734 let live: Vec<&str> = text
1735 .lines()
1736 .map(str::trim)
1737 .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
1738 .collect();
1739 assert!(
1740 live.is_empty(),
1741 "admin_key_env must stay commented out while it is inert: {live:?}"
1742 );
1743 assert!(
1746 text.contains("admin_key_env") && text.contains("RESERVED"),
1747 "the example should keep describing admin_key_env as reserved"
1748 );
1749 }
1750
1751 #[test]
1752 fn admin_key_env_is_accepted_but_changes_nothing() {
1753 let f = write_toml(
1757 r#"
1758 [openai]
1759 admin_key_env = "SOME_ADMIN_KEY"
1760 "#,
1761 );
1762 let c = Config::load_from(f.path()).unwrap();
1763 assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
1764 let default = OpenAiConfig::default();
1766 assert_eq!(c.openai.enabled, default.enabled);
1767 assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
1768 assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
1769 }
1770
1771 #[test]
1772 fn config_example_documents_every_vendor_without_secrets() {
1773 let raw = std::fs::read_to_string(config_example()).unwrap();
1774 let cfg = Config::load_from(&config_example()).unwrap();
1775 for id in VendorId::all() {
1778 let section = id.slug();
1779 assert!(
1780 raw.contains(&format!("[{section}]")),
1781 "config.example.toml has no [{section}] section"
1782 );
1783 }
1784
1785 assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
1788 assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
1789 assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
1790 assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
1791 assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
1792 assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
1793 }
1794
1795 #[test]
1796 fn cursor_db_path_is_tilde_expanded() {
1797 let f = write_toml(
1798 r#"
1799 [cursor]
1800 db_path = "~/cursor-state.vscdb"
1801 "#,
1802 );
1803 let c = Config::load_from(f.path()).unwrap();
1804 let home = crate::cache::home_dir().unwrap();
1805 assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
1806 }
1807
1808 #[test]
1809 fn cursor_appears_when_enabled() {
1810 let f = write_toml(
1811 r#"
1812 [cursor]
1813 enabled = true
1814 "#,
1815 );
1816 let c = Config::load_from(f.path()).unwrap();
1817 assert!(c.is_enabled(VendorId::Cursor));
1818 assert!(c.enabled_vendors().contains(&VendorId::Cursor));
1819 }
1820
1821 #[test]
1822 fn add_account_appends_and_preserves_existing() {
1823 let mut doc: toml_edit::DocumentMut = r#"
1824# keep me
1825[anthropic]
1826enabled = true
1827
1828[[anthropic.accounts]]
1829label = "personal"
1830credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
1831"#
1832 .parse()
1833 .unwrap();
1834 add_anthropic_account_to_doc(
1835 &mut doc,
1836 "work",
1837 "~/.config/ai-usagebar/accounts/work/.credentials.json",
1838 )
1839 .unwrap();
1840 let rendered = doc.to_string();
1841 assert!(rendered.contains("# keep me"), "comment must survive");
1842 let f = write_toml(&rendered);
1844 let c = Config::load_from(f.path()).unwrap();
1845 let labels: Vec<&str> = c
1846 .anthropic
1847 .accounts
1848 .iter()
1849 .map(|a| a.label.as_str())
1850 .collect();
1851 assert_eq!(labels, vec!["personal", "work"]);
1852 }
1853
1854 #[test]
1855 fn add_account_to_empty_doc_is_loadable() {
1856 let mut doc = toml_edit::DocumentMut::new();
1857 add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
1858 let f = write_toml(&doc.to_string());
1859 let c = Config::load_from(f.path()).unwrap();
1860 assert_eq!(c.anthropic.accounts.len(), 1);
1861 assert_eq!(c.anthropic.accounts[0].label, "solo");
1862 }
1863
1864 #[test]
1865 fn add_account_rejects_duplicate_label() {
1866 let mut doc: toml_edit::DocumentMut = r#"
1867[[anthropic.accounts]]
1868label = "work"
1869credentials_path = "~/w/.credentials.json"
1870"#
1871 .parse()
1872 .unwrap();
1873 assert!(
1874 add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
1875 "a duplicate label must be rejected, not appended"
1876 );
1877 }
1878
1879 #[test]
1880 fn add_account_rejects_bad_label() {
1881 let mut doc = toml_edit::DocumentMut::new();
1882 assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
1883 assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
1884 }
1885
1886 #[test]
1887 fn tildify_collapses_home_only() {
1888 let home = Path::new("/Users/me");
1889 assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
1890 assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
1891 }
1892
1893 #[test]
1894 fn default_account_credentials_path_nests_under_config_dir() {
1895 let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
1896 assert_eq!(
1897 default_account_credentials_path(cfg, "work"),
1898 Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
1899 );
1900 }
1901}