use std::fmt;
use std::path::PathBuf;
use crate::{discovery, env as env_loader, keychain};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialSource {
ProjectEnv,
Keychain,
GlobalEnv,
EnvironmentVariable,
}
impl fmt::Display for CredentialSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ProjectEnv => write!(f, "project .env"),
Self::Keychain => write!(f, "keychain"),
Self::GlobalEnv => write!(f, "global .env"),
Self::EnvironmentVariable => write!(f, "environment variable"),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedCredential {
pub value: String,
pub source: CredentialSource,
}
pub fn resolve_credential(
env_var_name: &str,
keychain_account: &str,
) -> Option<ResolvedCredential> {
if let Some(root) = discovery::find_project_root() {
let project_env = root.join(".life").join("credentials").join(".env");
if project_env.exists()
&& let Ok(vars) = env_loader::parse_env_file(&project_env)
&& let Some(val) = vars.get(env_var_name)
{
return Some(ResolvedCredential {
value: val.clone(),
source: CredentialSource::ProjectEnv,
});
}
}
if let Some(val) = keychain::read(keychain_account) {
return Some(ResolvedCredential {
value: val,
source: CredentialSource::Keychain,
});
}
let global_env = discovery::global_life_dir()
.join("credentials")
.join(".env");
if global_env.exists()
&& let Ok(vars) = env_loader::parse_env_file(&global_env)
&& let Some(val) = vars.get(env_var_name)
{
return Some(ResolvedCredential {
value: val.clone(),
source: CredentialSource::GlobalEnv,
});
}
if let Ok(val) = std::env::var(env_var_name) {
return Some(ResolvedCredential {
value: val,
source: CredentialSource::EnvironmentVariable,
});
}
None
}
pub fn store_credential(
env_var_name: &str,
keychain_account: &str,
value: &str,
) -> CredentialSource {
if keychain::store(keychain_account, value) {
tracing::info!("stored credential {env_var_name} in keychain");
return CredentialSource::Keychain;
}
let cred_dir = discovery::global_life_dir().join("credentials");
std::fs::create_dir_all(&cred_dir).ok();
let env_file = cred_dir.join(".env");
let mut content = std::fs::read_to_string(&env_file).unwrap_or_default();
let prefix = format!("{env_var_name}=");
let new_line = format!("{env_var_name}={value}");
let mut found = false;
let lines: Vec<String> = content
.lines()
.map(|line| {
if line.starts_with(&prefix) {
found = true;
new_line.clone()
} else {
line.to_string()
}
})
.collect();
if found {
content = lines.join("\n");
} else {
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&new_line);
content.push('\n');
}
std::fs::write(&env_file, &content).ok();
#[cfg(unix)]
{
set_restricted_permissions(&env_file);
}
tracing::info!("stored credential {env_var_name} in {}", env_file.display());
CredentialSource::GlobalEnv
}
#[cfg(unix)]
fn set_restricted_permissions(path: &PathBuf) {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms).ok();
}
pub fn provider_credential_names(provider: &str) -> (&'static str, &'static str) {
match provider {
"anthropic" => ("ANTHROPIC_API_KEY", "anthropic-api-key"),
"openai" => ("OPENAI_API_KEY", "openai-api-key"),
"google" | "gemini" => ("GOOGLE_API_KEY", "google-api-key"),
"mistral" => ("MISTRAL_API_KEY", "mistral-api-key"),
"cohere" => ("COHERE_API_KEY", "cohere-api-key"),
"groq" => ("GROQ_API_KEY", "groq-api-key"),
"deepseek" => ("DEEPSEEK_API_KEY", "deepseek-api-key"),
_ => ("LIFE_API_KEY", "life-api-key"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_from_env_var() {
let unique_key = "LIFE_PATHS_TEST_CRED_RESOLVE";
unsafe {
std::env::set_var(unique_key, "secret-from-env");
}
let result = resolve_credential(unique_key, "nonexistent-keychain-account");
assert!(result.is_some());
let cred = result.unwrap();
assert_eq!(cred.value, "secret-from-env");
assert_eq!(cred.source, CredentialSource::EnvironmentVariable);
unsafe {
std::env::remove_var(unique_key);
}
}
#[test]
fn missing_credential() {
let result = resolve_credential(
"LIFE_PATHS_ABSOLUTELY_NONEXISTENT_VAR_XYZ",
"nonexistent-keychain-account-xyz",
);
assert!(result.is_none());
}
#[test]
fn provider_names() {
let (env_var, kc) = provider_credential_names("anthropic");
assert_eq!(env_var, "ANTHROPIC_API_KEY");
assert_eq!(kc, "anthropic-api-key");
let (env_var, kc) = provider_credential_names("openai");
assert_eq!(env_var, "OPENAI_API_KEY");
assert_eq!(kc, "openai-api-key");
let (env_var, kc) = provider_credential_names("unknown");
assert_eq!(env_var, "LIFE_API_KEY");
assert_eq!(kc, "life-api-key");
}
#[test]
fn store_creates_env_file() {
let tmp = tempfile::TempDir::new().unwrap();
let fake_home = tmp.path().join("fakehome");
std::fs::create_dir_all(&fake_home).unwrap();
let cred_dir = fake_home.join(".life").join("credentials");
std::fs::create_dir_all(&cred_dir).unwrap();
let env_file = cred_dir.join(".env");
let content = "MY_KEY=my_value\n";
std::fs::write(&env_file, content).unwrap();
let read_back = std::fs::read_to_string(&env_file).unwrap();
assert!(read_back.contains("MY_KEY=my_value"));
}
}