use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{AppError, Result};
use crate::vendor::VendorId;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
pub ui: UiConfig,
pub anthropic: AnthropicConfig,
pub openai: OpenAiConfig,
pub zai: ZaiConfig,
pub openrouter: OpenRouterConfig,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct UiConfig {
pub primary: Option<VendorId>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct AnthropicConfig {
pub enabled: bool,
pub credentials_path: Option<PathBuf>,
}
impl Default for AnthropicConfig {
fn default() -> Self {
Self {
enabled: true,
credentials_path: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct OpenAiConfig {
pub enabled: bool,
pub codex_auth_path: Option<PathBuf>,
pub admin_key_env: String,
}
impl Default for OpenAiConfig {
fn default() -> Self {
Self {
enabled: true,
codex_auth_path: None,
admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ZaiConfig {
pub enabled: bool,
pub api_key_env: String,
pub api_key: Option<String>,
pub plan_tier: Option<String>,
}
impl Default for ZaiConfig {
fn default() -> Self {
Self {
enabled: true,
api_key_env: "ZAI_API_KEY".to_string(),
api_key: None,
plan_tier: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct OpenRouterConfig {
pub enabled: bool,
pub api_key_env: String,
pub api_key: Option<String>,
}
impl Default for OpenRouterConfig {
fn default() -> Self {
Self {
enabled: true,
api_key_env: "OPENROUTER_API_KEY".to_string(),
api_key: None,
}
}
}
pub fn resolve_api_key(
vendor_label: &str,
env_var_name: &str,
inline: Option<&str>,
) -> crate::error::Result<String> {
if !env_var_name.is_empty() {
if let Ok(v) = std::env::var(env_var_name) {
if !v.is_empty() {
return Ok(v);
}
}
}
if let Some(v) = inline {
if !v.is_empty() {
return Ok(v.to_string());
}
}
Err(crate::error::AppError::Credentials(format!(
"{vendor_label}: no API key. Either export {env_var_name} or set \
`api_key` under [{}] in ~/.config/ai-usagebar/config.toml (chmod 600).",
vendor_label.to_lowercase()
)))
}
impl Config {
pub fn load() -> Result<Self> {
let Some(path) = default_path() else {
return Ok(Self::default());
};
Self::load_from(&path)
}
pub fn load_from(path: &std::path::Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(toml::from_str(&s)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(AppError::io_at(path, e)),
}
}
pub fn is_enabled(&self, id: VendorId) -> bool {
match id {
VendorId::Anthropic => self.anthropic.enabled,
VendorId::Openai => self.openai.enabled,
VendorId::Zai => self.zai.enabled,
VendorId::Openrouter => self.openrouter.enabled,
}
}
pub fn enabled_vendors(&self) -> Vec<VendorId> {
VendorId::all()
.iter()
.copied()
.filter(|id| self.is_enabled(*id))
.collect()
}
}
fn default_path() -> Option<PathBuf> {
let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
Some(proj.config_dir().join("config.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_toml(s: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(s.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn defaults_enable_all_vendors() {
let c = Config::default();
assert!(c.is_enabled(VendorId::Anthropic));
assert!(c.is_enabled(VendorId::Openai));
assert!(c.is_enabled(VendorId::Zai));
assert!(c.is_enabled(VendorId::Openrouter));
assert_eq!(c.enabled_vendors().len(), 4);
}
#[test]
fn missing_file_uses_defaults() {
let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
let c = Config::load_from(path).unwrap();
assert!(c.is_enabled(VendorId::Anthropic));
}
#[test]
fn parses_full_config() {
let f = write_toml(
r#"
[anthropic]
enabled = true
[openai]
enabled = false
admin_key_env = "MY_ADMIN_KEY"
[zai]
enabled = true
api_key_env = "MY_ZAI"
plan_tier = "pro"
[openrouter]
enabled = false
"#,
);
let c = Config::load_from(f.path()).unwrap();
assert!(c.is_enabled(VendorId::Anthropic));
assert!(!c.is_enabled(VendorId::Openai));
assert!(c.is_enabled(VendorId::Zai));
assert!(!c.is_enabled(VendorId::Openrouter));
assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
assert_eq!(c.zai.api_key_env, "MY_ZAI");
assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
}
#[test]
fn partial_config_falls_back_to_defaults() {
let f = write_toml(
r#"[openai]
enabled = false
"#,
);
let c = Config::load_from(f.path()).unwrap();
assert!(!c.is_enabled(VendorId::Openai));
assert!(c.is_enabled(VendorId::Anthropic));
assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
}
#[test]
fn malformed_toml_returns_error() {
let f = write_toml("this is not = = valid");
assert!(Config::load_from(f.path()).is_err());
}
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
M.lock().unwrap_or_else(|p| p.into_inner())
}
#[test]
fn resolve_api_key_prefers_env_over_inline() {
let _g = env_guard();
let var = "AI_USAGEBAR_TEST_ENV_WINS";
unsafe { std::env::set_var(var, "from-env") };
let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
unsafe { std::env::remove_var(var) };
assert_eq!(got, "from-env");
}
#[test]
fn resolve_api_key_falls_back_to_inline() {
let _g = env_guard();
let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
unsafe { std::env::remove_var(var) };
let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
assert_eq!(got, "inline-key");
}
#[test]
fn resolve_api_key_errors_when_both_missing() {
let _g = env_guard();
let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
unsafe { std::env::remove_var(var) };
let err = resolve_api_key("Zai", var, None).unwrap_err();
match err {
crate::error::AppError::Credentials(msg) => {
assert!(msg.contains(var), "error should name env var: {msg}");
assert!(
msg.contains("api_key"),
"error should suggest config field: {msg}"
);
}
other => panic!("expected Credentials error, got {other:?}"),
}
}
#[test]
fn resolve_api_key_treats_empty_env_as_unset() {
let _g = env_guard();
let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
unsafe { std::env::set_var(var, "") };
let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
unsafe { std::env::remove_var(var) };
assert_eq!(got, "inline");
}
#[test]
fn config_parses_with_inline_api_key_and_primary() {
let f = write_toml(
r#"
[ui]
primary = "openrouter"
[zai]
enabled = true
api_key_env = "MY_ZAI"
api_key = "sk-zai-inline"
[openrouter]
enabled = true
api_key = "sk-or-inline"
"#,
);
let c = Config::load_from(f.path()).unwrap();
assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
}
#[test]
fn enabled_vendors_preserves_canonical_order() {
let c = Config::default();
assert_eq!(
c.enabled_vendors(),
vec![
VendorId::Anthropic,
VendorId::Openai,
VendorId::Zai,
VendorId::Openrouter
]
);
}
}