1use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::{AppError, Result};
20use crate::vendor::VendorId;
21
22#[derive(Debug, Clone, Default, Deserialize, Serialize)]
23#[serde(default)]
24pub struct Config {
25 pub ui: UiConfig,
26 pub anthropic: AnthropicConfig,
27 pub openai: OpenAiConfig,
28 pub zai: ZaiConfig,
29 pub openrouter: OpenRouterConfig,
30 pub deepseek: DeepseekConfig,
31}
32
33#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default)]
38pub struct UiConfig {
39 pub primary: Option<VendorId>,
41}
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
44#[serde(default)]
45pub struct AnthropicConfig {
46 pub enabled: bool,
47 pub credentials_path: Option<PathBuf>,
49}
50
51impl Default for AnthropicConfig {
52 fn default() -> Self {
53 Self {
54 enabled: true,
55 credentials_path: None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61#[serde(default)]
62pub struct OpenAiConfig {
63 pub enabled: bool,
64 pub codex_auth_path: Option<PathBuf>,
66 pub admin_key_env: String,
68}
69
70impl Default for OpenAiConfig {
71 fn default() -> Self {
72 Self {
73 enabled: true,
74 codex_auth_path: None,
75 admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(default)]
82pub struct ZaiConfig {
83 pub enabled: bool,
84 pub api_key_env: String,
86 pub api_key: Option<String>,
89 pub plan_tier: Option<String>,
91}
92
93impl Default for ZaiConfig {
94 fn default() -> Self {
95 Self {
96 enabled: true,
97 api_key_env: "ZAI_API_KEY".to_string(),
98 api_key: None,
99 plan_tier: None,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Deserialize, Serialize)]
105#[serde(default)]
106pub struct OpenRouterConfig {
107 pub enabled: bool,
108 pub api_key_env: String,
109 pub api_key: Option<String>,
110}
111
112impl Default for OpenRouterConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 api_key_env: "OPENROUTER_API_KEY".to_string(),
117 api_key: None,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Deserialize, Serialize)]
123#[serde(default)]
124pub struct DeepseekConfig {
125 pub enabled: bool,
126 pub api_key_env: String,
127 pub api_key: Option<String>,
128}
129
130impl Default for DeepseekConfig {
131 fn default() -> Self {
132 Self {
133 enabled: false,
134 api_key_env: "DEEPSEEK_API_KEY".to_string(),
135 api_key: None,
136 }
137 }
138}
139
140pub fn resolve_api_key(
143 vendor_label: &str,
144 env_var_name: &str,
145 inline: Option<&str>,
146) -> crate::error::Result<String> {
147 if !env_var_name.is_empty() {
148 if let Ok(v) = std::env::var(env_var_name) {
149 if !v.is_empty() {
150 return Ok(v);
151 }
152 }
153 }
154 if let Some(v) = inline {
155 if !v.is_empty() {
156 return Ok(v.to_string());
157 }
158 }
159 Err(crate::error::AppError::Credentials(format!(
160 "{vendor_label}: no API key. Either export {env_var_name} or set \
161 `api_key` under [{}] in {}.",
162 vendor_label.to_lowercase(),
163 config_path_hint()
164 )))
165}
166
167impl Config {
168 pub fn load() -> Result<Self> {
171 let Some(path) = default_path() else {
172 return Ok(Self::default());
173 };
174 Self::load_from(&path)
175 }
176
177 pub fn load_from(path: &std::path::Path) -> Result<Self> {
178 match std::fs::read_to_string(path) {
179 Ok(s) => Ok(toml::from_str(&s)?),
180 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
181 Err(e) => Err(AppError::io_at(path, e)),
182 }
183 }
184
185 pub fn is_enabled(&self, id: VendorId) -> bool {
186 match id {
187 VendorId::Anthropic => self.anthropic.enabled,
188 VendorId::Openai => self.openai.enabled,
189 VendorId::Zai => self.zai.enabled,
190 VendorId::Openrouter => self.openrouter.enabled,
191 VendorId::Deepseek => self.deepseek.enabled,
192 }
193 }
194
195 pub fn enabled_vendors(&self) -> Vec<VendorId> {
196 VendorId::all()
197 .iter()
198 .copied()
199 .filter(|id| self.is_enabled(*id))
200 .collect()
201 }
202}
203
204pub fn default_path() -> Option<PathBuf> {
205 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
206 Some(proj.config_dir().join("config.toml"))
207}
208
209pub fn config_path_hint() -> String {
214 default_path()
215 .map(|p| p.display().to_string())
216 .unwrap_or_else(|| "config.toml".to_string())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::io::Write;
223 use tempfile::NamedTempFile;
224
225 fn write_toml(s: &str) -> NamedTempFile {
226 let mut f = NamedTempFile::new().unwrap();
227 f.write_all(s.as_bytes()).unwrap();
228 f.flush().unwrap();
229 f
230 }
231
232 #[test]
233 fn defaults_enable_all_vendors() {
234 let c = Config::default();
235 assert!(c.is_enabled(VendorId::Anthropic));
236 assert!(c.is_enabled(VendorId::Openai));
237 assert!(c.is_enabled(VendorId::Zai));
238 assert!(c.is_enabled(VendorId::Openrouter));
239 assert!(!c.is_enabled(VendorId::Deepseek));
241 assert_eq!(c.enabled_vendors().len(), 4);
242 }
243
244 #[test]
245 fn missing_file_uses_defaults() {
246 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
247 let c = Config::load_from(path).unwrap();
248 assert!(c.is_enabled(VendorId::Anthropic));
249 }
250
251 #[test]
252 fn parses_full_config() {
253 let f = write_toml(
254 r#"
255 [anthropic]
256 enabled = true
257
258 [openai]
259 enabled = false
260 admin_key_env = "MY_ADMIN_KEY"
261
262 [zai]
263 enabled = true
264 api_key_env = "MY_ZAI"
265 plan_tier = "pro"
266
267 [openrouter]
268 enabled = false
269 "#,
270 );
271 let c = Config::load_from(f.path()).unwrap();
272 assert!(c.is_enabled(VendorId::Anthropic));
273 assert!(!c.is_enabled(VendorId::Openai));
274 assert!(c.is_enabled(VendorId::Zai));
275 assert!(!c.is_enabled(VendorId::Openrouter));
276 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
277 assert_eq!(c.zai.api_key_env, "MY_ZAI");
278 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
279 }
280
281 #[test]
282 fn partial_config_falls_back_to_defaults() {
283 let f = write_toml(
284 r#"[openai]
285enabled = false
286"#,
287 );
288 let c = Config::load_from(f.path()).unwrap();
289 assert!(!c.is_enabled(VendorId::Openai));
290 assert!(c.is_enabled(VendorId::Anthropic));
292 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
293 }
294
295 #[test]
296 fn malformed_toml_returns_error() {
297 let f = write_toml("this is not = = valid");
298 assert!(Config::load_from(f.path()).is_err());
299 }
300
301 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
303 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
304 M.lock().unwrap_or_else(|p| p.into_inner())
305 }
306
307 #[test]
308 fn resolve_api_key_prefers_env_over_inline() {
309 let _g = env_guard();
310 let var = "AI_USAGEBAR_TEST_ENV_WINS";
312 unsafe { std::env::set_var(var, "from-env") };
314 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
315 unsafe { std::env::remove_var(var) };
316 assert_eq!(got, "from-env");
317 }
318
319 #[test]
320 fn resolve_api_key_falls_back_to_inline() {
321 let _g = env_guard();
322 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
323 unsafe { std::env::remove_var(var) };
324 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
325 assert_eq!(got, "inline-key");
326 }
327
328 #[test]
329 fn resolve_api_key_errors_when_both_missing() {
330 let _g = env_guard();
331 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
332 unsafe { std::env::remove_var(var) };
333 let err = resolve_api_key("Zai", var, None).unwrap_err();
334 match err {
335 crate::error::AppError::Credentials(msg) => {
336 assert!(msg.contains(var), "error should name env var: {msg}");
337 assert!(
338 msg.contains("api_key"),
339 "error should suggest config field: {msg}"
340 );
341 }
342 other => panic!("expected Credentials error, got {other:?}"),
343 }
344 }
345
346 #[test]
347 fn config_path_hint_ends_with_config_toml() {
348 assert!(config_path_hint().ends_with("config.toml"));
351 }
352
353 #[test]
354 fn resolve_api_key_treats_empty_env_as_unset() {
355 let _g = env_guard();
356 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
357 unsafe { std::env::set_var(var, "") };
358 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
359 unsafe { std::env::remove_var(var) };
360 assert_eq!(got, "inline");
361 }
362
363 #[test]
364 fn config_parses_with_inline_api_key_and_primary() {
365 let f = write_toml(
366 r#"
367 [ui]
368 primary = "openrouter"
369
370 [zai]
371 enabled = true
372 api_key_env = "MY_ZAI"
373 api_key = "sk-zai-inline"
374
375 [openrouter]
376 enabled = true
377 api_key = "sk-or-inline"
378 "#,
379 );
380 let c = Config::load_from(f.path()).unwrap();
381 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
382 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
383 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
384 }
385
386 #[test]
387 fn enabled_vendors_preserves_canonical_order() {
388 let c = Config::default();
391 assert_eq!(
392 c.enabled_vendors(),
393 vec![
394 VendorId::Anthropic,
395 VendorId::Openai,
396 VendorId::Zai,
397 VendorId::Openrouter,
398 ]
399 );
400 }
401
402 #[test]
403 fn deepseek_appears_when_enabled() {
404 let f = write_toml(
405 r#"
406 [deepseek]
407 enabled = true
408 api_key = "sk-test"
409 "#,
410 );
411 let c = Config::load_from(f.path()).unwrap();
412 assert!(c.is_enabled(VendorId::Deepseek));
413 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
414 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
415 }
416}