1use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19use crate::anthropic::creds::CredsTarget;
20use crate::cache::Cache;
21use crate::error::{AppError, Result};
22use crate::vendor::VendorId;
23
24#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25#[serde(default)]
26pub struct Config {
27 pub ui: UiConfig,
28 pub anthropic: AnthropicConfig,
29 pub openai: OpenAiConfig,
30 pub zai: ZaiConfig,
31 pub openrouter: OpenRouterConfig,
32 pub deepseek: DeepseekConfig,
33}
34
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
39#[serde(default)]
40pub struct UiConfig {
41 pub primary: Option<VendorId>,
43}
44
45#[derive(Debug, Clone, Deserialize, Serialize)]
46#[serde(default)]
47pub struct AnthropicConfig {
48 pub enabled: bool,
49 pub credentials_path: Option<PathBuf>,
52 pub accounts: Vec<AnthropicAccount>,
56}
57
58impl Default for AnthropicConfig {
59 fn default() -> Self {
60 Self {
61 enabled: true,
62 credentials_path: None,
63 accounts: Vec::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
78pub struct AnthropicAccount {
79 pub label: String,
82 pub credentials_path: PathBuf,
86}
87
88impl AnthropicConfig {
89 pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
92 validate_account_label(label)?;
93 self.accounts
94 .iter()
95 .find(|a| a.label == label)
96 .ok_or_else(|| {
97 let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
98 AppError::Credentials(format!(
99 "anthropic account {label:?} not found in [[anthropic.accounts]]; \
100 known labels: {known:?}"
101 ))
102 })
103 }
104
105 pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
112 let account = self.account(label)?;
113 Ok((
114 CredsTarget::Explicit(account.credentials_path.clone()),
115 Cache::for_vendor_account("anthropic", label)?,
116 ))
117 }
118}
119
120fn validate_account_label(label: &str) -> Result<()> {
126 let bad = label.is_empty()
127 || label == "."
128 || label == ".."
129 || label.contains(['/', '\\'])
130 || label == "usage.json";
131 if bad {
132 return Err(AppError::Credentials(format!(
133 "invalid anthropic account label {label:?}: must be a non-empty name \
134 without path separators (it becomes a cache subdirectory)"
135 )));
136 }
137 Ok(())
138}
139
140#[derive(Debug, Clone, Deserialize, Serialize)]
141#[serde(default)]
142pub struct OpenAiConfig {
143 pub enabled: bool,
144 pub codex_auth_path: Option<PathBuf>,
146 pub admin_key_env: String,
148}
149
150impl Default for OpenAiConfig {
151 fn default() -> Self {
152 Self {
153 enabled: true,
154 codex_auth_path: None,
155 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
156 }
157 }
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize)]
161#[serde(default)]
162pub struct ZaiConfig {
163 pub enabled: bool,
164 pub api_key_env: String,
166 pub api_key: Option<String>,
169 pub plan_tier: Option<String>,
171}
172
173impl Default for ZaiConfig {
174 fn default() -> Self {
175 Self {
176 enabled: true,
177 api_key_env: "ZAI_API_KEY".to_string(),
178 api_key: None,
179 plan_tier: None,
180 }
181 }
182}
183
184#[derive(Debug, Clone, Deserialize, Serialize)]
185#[serde(default)]
186pub struct OpenRouterConfig {
187 pub enabled: bool,
188 pub api_key_env: String,
189 pub api_key: Option<String>,
190}
191
192impl Default for OpenRouterConfig {
193 fn default() -> Self {
194 Self {
195 enabled: true,
196 api_key_env: "OPENROUTER_API_KEY".to_string(),
197 api_key: None,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
203#[serde(default)]
204pub struct DeepseekConfig {
205 pub enabled: bool,
206 pub api_key_env: String,
207 pub api_key: Option<String>,
208}
209
210impl Default for DeepseekConfig {
211 fn default() -> Self {
212 Self {
213 enabled: false,
214 api_key_env: "DEEPSEEK_API_KEY".to_string(),
215 api_key: None,
216 }
217 }
218}
219
220pub fn resolve_api_key(
223 vendor_label: &str,
224 env_var_name: &str,
225 inline: Option<&str>,
226) -> crate::error::Result<String> {
227 if !env_var_name.is_empty()
228 && let Ok(v) = std::env::var(env_var_name)
229 && !v.is_empty()
230 {
231 return Ok(v);
232 }
233 if let Some(v) = inline
234 && !v.is_empty()
235 {
236 return Ok(v.to_string());
237 }
238 Err(crate::error::AppError::Credentials(format!(
239 "{vendor_label}: no API key. Either export {env_var_name} or set \
240 `api_key` under [{}] in {}.",
241 vendor_label.to_lowercase(),
242 config_path_hint()
243 )))
244}
245
246impl Config {
247 pub fn load() -> Result<Self> {
250 let Some(path) = default_path() else {
251 return Ok(Self::default());
252 };
253 Self::load_from(&path)
254 }
255
256 pub fn load_from(path: &std::path::Path) -> Result<Self> {
257 match std::fs::read_to_string(path) {
258 Ok(s) => Ok(toml::from_str(&s)?),
259 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
260 Err(e) => Err(AppError::io_at(path, e)),
261 }
262 }
263
264 pub fn is_enabled(&self, id: VendorId) -> bool {
265 match id {
266 VendorId::Anthropic => self.anthropic.enabled,
267 VendorId::Openai => self.openai.enabled,
268 VendorId::Zai => self.zai.enabled,
269 VendorId::Openrouter => self.openrouter.enabled,
270 VendorId::Deepseek => self.deepseek.enabled,
271 }
272 }
273
274 pub fn enabled_vendors(&self) -> Vec<VendorId> {
275 VendorId::all()
276 .iter()
277 .copied()
278 .filter(|id| self.is_enabled(*id))
279 .collect()
280 }
281}
282
283pub fn default_path() -> Option<PathBuf> {
284 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
285 Some(proj.config_dir().join("config.toml"))
286}
287
288pub fn config_path_hint() -> String {
293 default_path()
294 .map(|p| p.display().to_string())
295 .unwrap_or_else(|| "config.toml".to_string())
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use std::io::Write;
302 use tempfile::NamedTempFile;
303
304 fn write_toml(s: &str) -> NamedTempFile {
305 let mut f = NamedTempFile::new().unwrap();
306 f.write_all(s.as_bytes()).unwrap();
307 f.flush().unwrap();
308 f
309 }
310
311 #[test]
312 fn defaults_enable_all_vendors() {
313 let c = Config::default();
314 assert!(c.is_enabled(VendorId::Anthropic));
315 assert!(c.is_enabled(VendorId::Openai));
316 assert!(c.is_enabled(VendorId::Zai));
317 assert!(c.is_enabled(VendorId::Openrouter));
318 assert!(!c.is_enabled(VendorId::Deepseek));
320 assert_eq!(c.enabled_vendors().len(), 4);
321 }
322
323 #[test]
324 fn missing_file_uses_defaults() {
325 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
326 let c = Config::load_from(path).unwrap();
327 assert!(c.is_enabled(VendorId::Anthropic));
328 }
329
330 #[test]
331 fn parses_full_config() {
332 let f = write_toml(
333 r#"
334 [anthropic]
335 enabled = true
336
337 [openai]
338 enabled = false
339 admin_key_env = "MY_ADMIN_KEY"
340
341 [zai]
342 enabled = true
343 api_key_env = "MY_ZAI"
344 plan_tier = "pro"
345
346 [openrouter]
347 enabled = false
348 "#,
349 );
350 let c = Config::load_from(f.path()).unwrap();
351 assert!(c.is_enabled(VendorId::Anthropic));
352 assert!(!c.is_enabled(VendorId::Openai));
353 assert!(c.is_enabled(VendorId::Zai));
354 assert!(!c.is_enabled(VendorId::Openrouter));
355 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
356 assert_eq!(c.zai.api_key_env, "MY_ZAI");
357 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
358 }
359
360 #[test]
361 fn partial_config_falls_back_to_defaults() {
362 let f = write_toml(
363 r#"[openai]
364enabled = false
365"#,
366 );
367 let c = Config::load_from(f.path()).unwrap();
368 assert!(!c.is_enabled(VendorId::Openai));
369 assert!(c.is_enabled(VendorId::Anthropic));
371 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
372 }
373
374 #[test]
375 fn malformed_toml_returns_error() {
376 let f = write_toml("this is not = = valid");
377 assert!(Config::load_from(f.path()).is_err());
378 }
379
380 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
382 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
383 M.lock().unwrap_or_else(|p| p.into_inner())
384 }
385
386 #[test]
387 fn resolve_api_key_prefers_env_over_inline() {
388 let _g = env_guard();
389 let var = "AI_USAGEBAR_TEST_ENV_WINS";
391 unsafe { std::env::set_var(var, "from-env") };
393 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
394 unsafe { std::env::remove_var(var) };
395 assert_eq!(got, "from-env");
396 }
397
398 #[test]
399 fn resolve_api_key_falls_back_to_inline() {
400 let _g = env_guard();
401 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
402 unsafe { std::env::remove_var(var) };
403 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
404 assert_eq!(got, "inline-key");
405 }
406
407 #[test]
408 fn resolve_api_key_errors_when_both_missing() {
409 let _g = env_guard();
410 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
411 unsafe { std::env::remove_var(var) };
412 let err = resolve_api_key("Zai", var, None).unwrap_err();
413 match err {
414 crate::error::AppError::Credentials(msg) => {
415 assert!(msg.contains(var), "error should name env var: {msg}");
416 assert!(
417 msg.contains("api_key"),
418 "error should suggest config field: {msg}"
419 );
420 }
421 other => panic!("expected Credentials error, got {other:?}"),
422 }
423 }
424
425 #[test]
426 fn config_path_hint_ends_with_config_toml() {
427 assert!(config_path_hint().ends_with("config.toml"));
430 }
431
432 #[test]
433 fn resolve_api_key_treats_empty_env_as_unset() {
434 let _g = env_guard();
435 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
436 unsafe { std::env::set_var(var, "") };
437 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
438 unsafe { std::env::remove_var(var) };
439 assert_eq!(got, "inline");
440 }
441
442 #[test]
443 fn config_parses_with_inline_api_key_and_primary() {
444 let f = write_toml(
445 r#"
446 [ui]
447 primary = "openrouter"
448
449 [zai]
450 enabled = true
451 api_key_env = "MY_ZAI"
452 api_key = "sk-zai-inline"
453
454 [openrouter]
455 enabled = true
456 api_key = "sk-or-inline"
457 "#,
458 );
459 let c = Config::load_from(f.path()).unwrap();
460 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
461 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
462 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
463 }
464
465 #[test]
466 fn enabled_vendors_preserves_canonical_order() {
467 let c = Config::default();
470 assert_eq!(
471 c.enabled_vendors(),
472 vec![
473 VendorId::Anthropic,
474 VendorId::Openai,
475 VendorId::Zai,
476 VendorId::Openrouter,
477 ]
478 );
479 }
480
481 #[test]
482 fn deepseek_appears_when_enabled() {
483 let f = write_toml(
484 r#"
485 [deepseek]
486 enabled = true
487 api_key = "sk-test"
488 "#,
489 );
490 let c = Config::load_from(f.path()).unwrap();
491 assert!(c.is_enabled(VendorId::Deepseek));
492 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
493 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
494 }
495
496 #[test]
497 fn parses_anthropic_accounts_and_looks_them_up() {
498 let f = write_toml(
499 r#"
500 [anthropic]
501 enabled = true
502
503 [[anthropic.accounts]]
504 label = "personal"
505 credentials_path = "/creds/personal.json"
506
507 [[anthropic.accounts]]
508 label = "work"
509 credentials_path = "/creds/work.json"
510 "#,
511 );
512 let c = Config::load_from(f.path()).unwrap();
513 assert_eq!(c.anthropic.accounts.len(), 2);
514 let work = c.anthropic.account("work").unwrap();
515 assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
516 let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
518 assert!(err.contains("missing") && err.contains("work"), "{err}");
519 }
520
521 #[test]
522 fn account_label_rejects_path_like_names() {
523 let cfg = AnthropicConfig::default();
524 for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
525 let err = cfg.account(bad).unwrap_err();
526 assert!(
527 format!("{err:?}").contains("invalid anthropic account label"),
528 "{bad:?} should be rejected as a label"
529 );
530 }
531 }
532
533 #[test]
534 fn anthropic_accounts_default_to_empty() {
535 assert!(Config::default().anthropic.accounts.is_empty());
538 }
539}