use crate::error::BobError;
use crate::{KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use keyring::Entry;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum KeySource {
Env,
Keychain,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct AuthState {
#[serde(default = "default_schema")]
schema: u32,
#[serde(default)]
has_keychain_key: bool,
}
fn default_schema() -> u32 {
1
}
fn auth_state_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("com.compose.app").join("auth_state.json"))
}
fn read_auth_state() -> AuthState {
let Some(path) = auth_state_path() else {
return AuthState::default();
};
let Ok(bytes) = std::fs::read(&path) else {
return AuthState::default();
};
serde_json::from_slice::<AuthState>(&bytes).unwrap_or_default()
}
fn write_auth_state(state: &AuthState) -> Result<(), BobError> {
let path = auth_state_path().ok_or(BobError::NoDataDir)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| BobError::Io {
context: "create auth-state directory",
source,
})?;
}
let bytes = serde_json::to_vec_pretty(state)?;
std::fs::write(&path, bytes).map_err(|source| BobError::Io {
context: "write auth-state marker",
source,
})
}
static KEY_CACHE: Mutex<Option<(String, KeySource)>> = Mutex::new(None);
fn cache_read() -> Option<(String, KeySource)> {
KEY_CACHE.lock().ok().and_then(|guard| guard.clone())
}
fn cache_write(value: String, source: KeySource) {
if let Ok(mut guard) = KEY_CACHE.lock() {
*guard = Some((value, source));
}
}
fn cache_clear() {
if let Ok(mut guard) = KEY_CACHE.lock() {
*guard = None;
}
}
pub fn auth_source() -> Option<KeySource> {
if let Ok(value) = std::env::var("BOBSHELL_API_KEY") {
if !value.is_empty() {
return Some(KeySource::Env);
}
}
if read_auth_state().has_keychain_key {
Some(KeySource::Keychain)
} else {
None
}
}
pub fn read_api_key() -> Option<String> {
let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT).ok()?;
match entry.get_password() {
Ok(value) if !value.trim().is_empty() => Some(value),
_ => None,
}
}
pub fn write_api_key(key: &str) -> Result<(), BobError> {
if key.trim().is_empty() {
return Err(BobError::Invalid("API key must be non-empty".to_owned()));
}
let entry = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
entry.set_password(key)?;
write_auth_state(&AuthState {
schema: 1,
has_keychain_key: true,
})?;
cache_write(key.to_owned(), KeySource::Keychain);
Ok(())
}
pub fn delete_api_key() -> Result<(), BobError> {
if let Ok(entry) = Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) {
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(err) => return Err(BobError::Keychain(err)),
}
}
write_auth_state(&AuthState {
schema: 1,
has_keychain_key: false,
})
.ok();
cache_clear();
Ok(())
}
pub fn resolve_api_key() -> Option<(String, KeySource)> {
if let Some(cached) = cache_read() {
return Some(cached);
}
if let Ok(value) = std::env::var("BOBSHELL_API_KEY") {
if !value.is_empty() {
cache_write(value.clone(), KeySource::Env);
return Some((value, KeySource::Env));
}
}
let value = read_api_key()?;
cache_write(value.clone(), KeySource::Keychain);
Some((value, KeySource::Keychain))
}