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 && let Ok(v) = std::env::var(env_var_name)
149 && !v.is_empty()
150 {
151 return Ok(v);
152 }
153 if let Some(v) = inline
154 && !v.is_empty()
155 {
156 return Ok(v.to_string());
157 }
158 Err(crate::error::AppError::Credentials(format!(
159 "{vendor_label}: no API key. Either export {env_var_name} or set \
160 `api_key` under [{}] in {}.",
161 vendor_label.to_lowercase(),
162 config_path_hint()
163 )))
164}
165
166impl Config {
167 pub fn load() -> Result<Self> {
170 let Some(path) = default_path() else {
171 return Ok(Self::default());
172 };
173 Self::load_from(&path)
174 }
175
176 pub fn load_from(path: &std::path::Path) -> Result<Self> {
177 match std::fs::read_to_string(path) {
178 Ok(s) => Ok(toml::from_str(&s)?),
179 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
180 Err(e) => Err(AppError::io_at(path, e)),
181 }
182 }
183
184 pub fn is_enabled(&self, id: VendorId) -> bool {
185 match id {
186 VendorId::Anthropic => self.anthropic.enabled,
187 VendorId::Openai => self.openai.enabled,
188 VendorId::Zai => self.zai.enabled,
189 VendorId::Openrouter => self.openrouter.enabled,
190 VendorId::Deepseek => self.deepseek.enabled,
191 }
192 }
193
194 pub fn enabled_vendors(&self) -> Vec<VendorId> {
195 VendorId::all()
196 .iter()
197 .copied()
198 .filter(|id| self.is_enabled(*id))
199 .collect()
200 }
201}
202
203pub fn default_path() -> Option<PathBuf> {
204 let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
205 Some(proj.config_dir().join("config.toml"))
206}
207
208pub fn config_path_hint() -> String {
213 default_path()
214 .map(|p| p.display().to_string())
215 .unwrap_or_else(|| "config.toml".to_string())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::io::Write;
222 use tempfile::NamedTempFile;
223
224 fn write_toml(s: &str) -> NamedTempFile {
225 let mut f = NamedTempFile::new().unwrap();
226 f.write_all(s.as_bytes()).unwrap();
227 f.flush().unwrap();
228 f
229 }
230
231 #[test]
232 fn defaults_enable_all_vendors() {
233 let c = Config::default();
234 assert!(c.is_enabled(VendorId::Anthropic));
235 assert!(c.is_enabled(VendorId::Openai));
236 assert!(c.is_enabled(VendorId::Zai));
237 assert!(c.is_enabled(VendorId::Openrouter));
238 assert!(!c.is_enabled(VendorId::Deepseek));
240 assert_eq!(c.enabled_vendors().len(), 4);
241 }
242
243 #[test]
244 fn missing_file_uses_defaults() {
245 let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
246 let c = Config::load_from(path).unwrap();
247 assert!(c.is_enabled(VendorId::Anthropic));
248 }
249
250 #[test]
251 fn parses_full_config() {
252 let f = write_toml(
253 r#"
254 [anthropic]
255 enabled = true
256
257 [openai]
258 enabled = false
259 admin_key_env = "MY_ADMIN_KEY"
260
261 [zai]
262 enabled = true
263 api_key_env = "MY_ZAI"
264 plan_tier = "pro"
265
266 [openrouter]
267 enabled = false
268 "#,
269 );
270 let c = Config::load_from(f.path()).unwrap();
271 assert!(c.is_enabled(VendorId::Anthropic));
272 assert!(!c.is_enabled(VendorId::Openai));
273 assert!(c.is_enabled(VendorId::Zai));
274 assert!(!c.is_enabled(VendorId::Openrouter));
275 assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
276 assert_eq!(c.zai.api_key_env, "MY_ZAI");
277 assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
278 }
279
280 #[test]
281 fn partial_config_falls_back_to_defaults() {
282 let f = write_toml(
283 r#"[openai]
284enabled = false
285"#,
286 );
287 let c = Config::load_from(f.path()).unwrap();
288 assert!(!c.is_enabled(VendorId::Openai));
289 assert!(c.is_enabled(VendorId::Anthropic));
291 assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
292 }
293
294 #[test]
295 fn malformed_toml_returns_error() {
296 let f = write_toml("this is not = = valid");
297 assert!(Config::load_from(f.path()).is_err());
298 }
299
300 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
302 static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
303 M.lock().unwrap_or_else(|p| p.into_inner())
304 }
305
306 #[test]
307 fn resolve_api_key_prefers_env_over_inline() {
308 let _g = env_guard();
309 let var = "AI_USAGEBAR_TEST_ENV_WINS";
311 unsafe { std::env::set_var(var, "from-env") };
313 let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
314 unsafe { std::env::remove_var(var) };
315 assert_eq!(got, "from-env");
316 }
317
318 #[test]
319 fn resolve_api_key_falls_back_to_inline() {
320 let _g = env_guard();
321 let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
322 unsafe { std::env::remove_var(var) };
323 let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
324 assert_eq!(got, "inline-key");
325 }
326
327 #[test]
328 fn resolve_api_key_errors_when_both_missing() {
329 let _g = env_guard();
330 let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
331 unsafe { std::env::remove_var(var) };
332 let err = resolve_api_key("Zai", var, None).unwrap_err();
333 match err {
334 crate::error::AppError::Credentials(msg) => {
335 assert!(msg.contains(var), "error should name env var: {msg}");
336 assert!(
337 msg.contains("api_key"),
338 "error should suggest config field: {msg}"
339 );
340 }
341 other => panic!("expected Credentials error, got {other:?}"),
342 }
343 }
344
345 #[test]
346 fn config_path_hint_ends_with_config_toml() {
347 assert!(config_path_hint().ends_with("config.toml"));
350 }
351
352 #[test]
353 fn resolve_api_key_treats_empty_env_as_unset() {
354 let _g = env_guard();
355 let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
356 unsafe { std::env::set_var(var, "") };
357 let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
358 unsafe { std::env::remove_var(var) };
359 assert_eq!(got, "inline");
360 }
361
362 #[test]
363 fn config_parses_with_inline_api_key_and_primary() {
364 let f = write_toml(
365 r#"
366 [ui]
367 primary = "openrouter"
368
369 [zai]
370 enabled = true
371 api_key_env = "MY_ZAI"
372 api_key = "sk-zai-inline"
373
374 [openrouter]
375 enabled = true
376 api_key = "sk-or-inline"
377 "#,
378 );
379 let c = Config::load_from(f.path()).unwrap();
380 assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
381 assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
382 assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
383 }
384
385 #[test]
386 fn enabled_vendors_preserves_canonical_order() {
387 let c = Config::default();
390 assert_eq!(
391 c.enabled_vendors(),
392 vec![
393 VendorId::Anthropic,
394 VendorId::Openai,
395 VendorId::Zai,
396 VendorId::Openrouter,
397 ]
398 );
399 }
400
401 #[test]
402 fn deepseek_appears_when_enabled() {
403 let f = write_toml(
404 r#"
405 [deepseek]
406 enabled = true
407 api_key = "sk-test"
408 "#,
409 );
410 let c = Config::load_from(f.path()).unwrap();
411 assert!(c.is_enabled(VendorId::Deepseek));
412 assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
413 assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
414 }
415}