use std::path::Path;
use std::sync::OnceLock;
static DOTENV_LOADED: OnceLock<bool> = OnceLock::new();
pub fn load_dotenv() {
DOTENV_LOADED.get_or_init(|| {
load_dotenv_impl();
true
});
}
#[doc(hidden)]
pub fn reload_dotenv() {
load_dotenv_impl();
}
fn load_dotenv_impl() {
let env_paths = [".env", "../.env", "../../.env", "../../../.env"];
for path_str in env_paths {
let path = Path::new(path_str);
if let Ok(contents) = std::fs::read_to_string(path) {
parse_dotenv(&contents);
return;
}
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let workspace_env = Path::new(&manifest_dir).join("../.env");
if let Ok(contents) = std::fs::read_to_string(&workspace_env) {
parse_dotenv(&contents);
}
}
if std::env::var("HF_TOKEN").is_err() {
if let Ok(v) = std::env::var("HF_API_TOKEN") {
std::env::set_var("HF_TOKEN", v.clone());
if std::env::var("HUGGINGFACE_HUB_TOKEN").is_err() {
std::env::set_var("HUGGINGFACE_HUB_TOKEN", v);
}
}
}
}
fn parse_dotenv(contents: &str) {
for line in contents.lines() {
let mut line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("export ") {
line = rest.trim();
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
if std::env::var(key).is_err() {
std::env::set_var(key, value);
}
}
}
}
#[must_use]
pub fn has_hf_token() -> bool {
std::env::var("HF_TOKEN").is_ok() || std::env::var("HF_API_TOKEN").is_ok()
}
#[must_use]
pub fn hf_token() -> Option<String> {
std::env::var("HF_TOKEN")
.ok()
.or_else(|| std::env::var("HF_API_TOKEN").ok())
}
#[must_use]
pub fn has_llm_api_key() -> bool {
fn nonempty(name: &str) -> bool {
std::env::var(name)
.ok()
.is_some_and(|v| !v.trim().is_empty())
}
nonempty("OPENROUTER_API_KEY")
|| nonempty("ANTHROPIC_API_KEY")
|| nonempty("GEMINI_API_KEY")
|| nonempty("GROQ_API_KEY")
|| nonempty("OLLAMA_HOST")
|| std::net::TcpStream::connect("127.0.0.1:11434").is_ok()
}
#[cfg(feature = "llm")]
#[must_use]
pub(crate) fn llm_api_key() -> Option<(String, &'static str)> {
let nonempty = |name: &str| -> Option<String> {
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
};
if let Some(key) = nonempty("OPENROUTER_API_KEY") {
return Some((key, "openrouter"));
}
if let Some(key) = nonempty("ANTHROPIC_API_KEY") {
return Some((key, "anthropic"));
}
if let Some(key) = nonempty("GEMINI_API_KEY") {
return Some((key, "gemini"));
}
if let Some(key) = nonempty("GROQ_API_KEY") {
return Some((key, "groq"));
}
if nonempty("OLLAMA_HOST").is_some() || std::net::TcpStream::connect("127.0.0.1:11434").is_ok()
{
return Some(("ollama".to_string(), "ollama"));
}
None
}
#[must_use]
pub fn cache_dir() -> std::path::PathBuf {
if let Ok(dir) = std::env::var("ANNO_CACHE_DIR") {
return std::path::PathBuf::from(dir);
}
#[cfg(feature = "analysis")]
{
#[cfg(target_os = "macos")]
{
dirs::home_dir()
.map(|h| h.join("Library/Caches/anno"))
.unwrap_or_else(|| std::path::PathBuf::from(".anno-cache"))
}
#[cfg(target_os = "linux")]
{
dirs::cache_dir()
.map(|c| c.join("anno"))
.unwrap_or_else(|| std::path::PathBuf::from(".anno-cache"))
}
#[cfg(target_os = "windows")]
{
dirs::cache_dir()
.map(|c| c.join("anno"))
.unwrap_or_else(|| std::path::PathBuf::from(".anno-cache"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
std::path::PathBuf::from(".anno-cache")
}
}
#[cfg(not(feature = "analysis"))]
{
std::path::PathBuf::from(".anno-cache")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_dotenv() {
let contents = r#"
# Comment
KEY1=value1
KEY2="quoted value"
KEY3='single quoted'
SPACED_KEY = spaced_value
"#;
let test_prefix = format!("ANNO_TEST_{}", std::process::id());
let test_contents = contents.replace("KEY", &test_prefix);
parse_dotenv(&test_contents);
}
#[test]
fn test_parse_dotenv_supports_export_prefix_and_sets_values() {
let pid = std::process::id();
let k1 = format!("ANNO_TEST_EXPORT_{}_K1", pid);
let k2 = format!("ANNO_TEST_EXPORT_{}_K2", pid);
std::env::remove_var(&k1);
std::env::remove_var(&k2);
let contents = format!(
r#"
export {k1}=value1
{k2}="quoted value"
"#
);
parse_dotenv(&contents);
assert_eq!(std::env::var(&k1).as_deref(), Ok("value1"));
assert_eq!(std::env::var(&k2).as_deref(), Ok("quoted value"));
std::env::remove_var(&k1);
std::env::remove_var(&k2);
}
#[test]
fn test_parse_dotenv_does_not_override_existing_env() {
let pid = std::process::id();
let key = format!("ANNO_TEST_NO_OVERRIDE_{}", pid);
std::env::set_var(&key, "from_env");
let contents = format!(r#"{key}=from_dotenv"#);
parse_dotenv(&contents);
assert_eq!(std::env::var(&key).as_deref(), Ok("from_env"));
std::env::remove_var(&key);
}
#[test]
fn test_load_dotenv_idempotent() {
load_dotenv();
load_dotenv();
load_dotenv();
}
#[test]
fn test_cache_dir() {
let dir = cache_dir();
assert!(!dir.as_os_str().is_empty());
}
}