use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
pub const ENV_PREFIX: &str = "TALAHON_";
pub const LEGACY_ENV_PREFIX: &str = "TALON_";
pub fn legacy_key(key: &str) -> Option<String> {
key.strip_prefix(ENV_PREFIX)
.filter(|rest| !rest.is_empty())
.map(|rest| format!("{LEGACY_ENV_PREFIX}{rest}"))
}
pub fn current_key(key: &str) -> Option<String> {
key.strip_prefix(LEGACY_ENV_PREFIX)
.filter(|rest| !rest.is_empty())
.map(|rest| format!("{ENV_PREFIX}{rest}"))
}
pub fn key_spellings(key: &str) -> Vec<String> {
let mut out = vec![key.to_string()];
out.extend(legacy_key(key));
out
}
#[derive(Debug, Default)]
pub struct LegacyKeyLog {
seen: BTreeSet<String>,
pending: Vec<String>,
}
impl LegacyKeyLog {
pub fn note(&mut self, legacy: &str) -> bool {
self.seen.insert(legacy.to_string())
}
pub fn seen(&self) -> Vec<String> {
self.seen.iter().cloned().collect()
}
}
fn legacy_log() -> &'static Mutex<LegacyKeyLog> {
static LOG: OnceLock<Mutex<LegacyKeyLog>> = OnceLock::new();
LOG.get_or_init(Mutex::default)
}
fn warn_legacy_key(legacy: &str) {
let current = current_key(legacy).unwrap_or_default();
tracing::warn!(
deprecated = legacy,
replacement = %current,
"{legacy} is deprecated: rename it to {current} (the old name is removed in the next major version)"
);
}
pub fn note_legacy_key(legacy: &str) -> bool {
let logger = tracing::dispatcher::has_been_set();
let mut due = Vec::new();
let first = {
let mut log = legacy_log().lock().unwrap_or_else(PoisonError::into_inner);
let first = log.note(legacy);
if first {
log.pending.push(legacy.to_string());
}
if logger {
due = std::mem::take(&mut log.pending);
}
first
};
for key in due {
warn_legacy_key(&key);
}
first
}
pub fn flush_legacy_key_warnings() {
let due = {
let mut log = legacy_log().lock().unwrap_or_else(PoisonError::into_inner);
std::mem::take(&mut log.pending)
};
for key in due {
warn_legacy_key(&key);
}
}
pub fn legacy_keys_used() -> Vec<String> {
legacy_log()
.lock()
.unwrap_or_else(PoisonError::into_inner)
.seen()
}
pub fn resolve_with(key: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<String> {
if let Some(value) = lookup(key) {
return Some(value);
}
let legacy = legacy_key(key)?;
let value = lookup(&legacy)?;
note_legacy_key(&legacy);
Some(value)
}
pub fn process_var(key: &str) -> Option<String> {
resolve_with(key, |k| std::env::var(k).ok())
}
pub fn process_var_is_set(key: &str) -> bool {
std::env::var_os(key).is_some()
|| legacy_key(key).is_some_and(|k| std::env::var_os(k).is_some())
}
pub fn legacy_process_key_in_use(key: &str) -> Option<String> {
if std::env::var_os(key).is_some() {
return None;
}
let legacy = legacy_key(key)?;
std::env::var_os(&legacy)?;
note_legacy_key(&legacy);
Some(legacy)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum EnvSource {
Process,
File,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct LegacyKey {
pub legacy: String,
pub current: String,
pub source: EnvSource,
pub conflict: bool,
}
#[derive(Debug, Clone, Default)]
pub struct EnvFile {
path: Option<PathBuf>,
values: HashMap<String, String>,
}
impl EnvFile {
pub fn load(project_root: &Path) -> Self {
let path = project_root.join(".env");
match std::fs::read_to_string(&path) {
Ok(content) => Self {
path: Some(path),
values: parse_env(&content),
},
Err(_) => Self::default(),
}
}
pub fn from_str_content(content: &str) -> Self {
Self {
path: None,
values: parse_env(content),
}
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn file_value(&self, key: &str) -> Option<&str> {
if let Some(value) = self.values.get(key) {
return Some(value.as_str());
}
let legacy = legacy_key(key)?;
let value = self.values.get(&legacy)?;
note_legacy_key(&legacy);
Some(value.as_str())
}
pub fn get(&self, key: &str) -> Option<String> {
let lookup = |k: &str| {
std::env::var(k)
.ok()
.filter(|v| !v.is_empty())
.or_else(|| self.values.get(k).cloned())
};
resolve_with(key, lookup)
}
pub fn legacy_keys(&self) -> Vec<LegacyKey> {
let process: HashMap<String, String> = std::env::vars_os()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
.filter(|(_, v)| !v.is_empty())
.collect();
self.legacy_keys_in(&process)
}
pub fn legacy_keys_in(&self, process: &HashMap<String, String>) -> Vec<LegacyKey> {
let current_value = |current: &str| {
process
.get(current)
.or_else(|| self.values.get(current))
.map(String::as_str)
};
let mut out = Vec::new();
for (source, map) in [
(EnvSource::Process, process),
(EnvSource::File, &self.values),
] {
for (key, value) in map {
let Some(current) = current_key(key) else {
continue;
};
let conflict = current_value(¤t).is_some_and(|v| v != value);
out.push(LegacyKey {
legacy: key.clone(),
current,
source,
conflict,
});
}
}
out.sort();
out
}
pub fn get_u64(&self, key: &str, default: u64, min: u64) -> u64 {
self.get(key)
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(default)
.max(min)
}
pub fn get_bool(&self, key: &str) -> bool {
matches!(
self.get(key).as_deref().map(str::trim),
Some("1" | "true" | "TRUE" | "True" | "yes" | "on")
)
}
}
pub fn parse_env(content: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let Some(eq) = trimmed.find('=') else {
continue;
};
let key = trimmed[..eq].trim();
if key.is_empty() {
continue;
}
let value = value_of(&trimmed[eq + 1..]);
if !value.is_empty() {
out.insert(key.to_string(), value.to_string());
}
}
out
}
fn value_of(raw: &str) -> &str {
let value = raw.trim();
if let Some(quote) = value.chars().next().filter(|c| matches!(c, '"' | '\'')) {
if value.len() >= 2 && value.ends_with(quote) {
return &value[1..value.len() - 1];
}
return match value[1..].find(quote) {
Some(end) => &value[1..=end],
None => value,
};
}
let comment = raw
.char_indices()
.find(|&(i, c)| c == '#' && raw[..i].ends_with(char::is_whitespace))
.map_or(raw.len(), |(i, _)| i);
raw[..comment].trim()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_quotes_comments_and_export() {
let env = EnvFile::from_str_content(
"# c\nA=1\nexport B=\"two words\"\nC='x'\nEMPTY=\n D = spaced \n",
);
assert_eq!(env.file_value("A"), Some("1"));
assert_eq!(env.file_value("B"), Some("two words"));
assert_eq!(env.file_value("C"), Some("x"));
assert_eq!(env.file_value("EMPTY"), None);
assert_eq!(env.file_value("D"), Some("spaced"));
}
#[test]
fn inline_comments_are_not_part_of_the_value() {
let env = EnvFile::from_str_content(concat!(
"TALON_VLLM_FAST_MODEL=Qwen/Qwen3-8B # optional: compaction\n",
"TALON_VLLM_MAX_CONTEXT=131072\t# tokens\n",
"REQUIRED=true # strict\n",
"QUOTED=\"a # b\" # comment\n",
"SINGLE='x y' # c\n",
"HASH=abc#123\n",
"URL=http://h/#frag\n",
"ONLY= # nothing here\n",
));
assert_eq!(
env.file_value("TALON_VLLM_FAST_MODEL"),
Some("Qwen/Qwen3-8B")
);
assert_eq!(env.file_value("TALON_VLLM_MAX_CONTEXT"), Some("131072"));
assert_eq!(env.file_value("REQUIRED"), Some("true"));
assert_eq!(env.file_value("QUOTED"), Some("a # b"));
assert_eq!(env.file_value("SINGLE"), Some("x y"));
assert_eq!(env.file_value("HASH"), Some("abc#123"));
assert_eq!(env.file_value("URL"), Some("http://h/#frag"));
assert_eq!(env.file_value("ONLY"), None);
}
#[test]
fn new_key_falls_back_to_the_old_spelling_in_the_file() {
let env = EnvFile::from_str_content("TALON_ENVTEST_ONLY_OLD=legacy\nOTHER=1\n");
assert_eq!(env.file_value("TALAHON_ENVTEST_ONLY_OLD"), Some("legacy"));
assert_eq!(
env.get("TALAHON_ENVTEST_ONLY_OLD").as_deref(),
Some("legacy")
);
assert_eq!(env.file_value("TALON_ENVTEST_ONLY_OLD"), Some("legacy"));
assert_eq!(env.file_value("TALAHON_OTHER"), None);
assert!(legacy_keys_used().contains(&"TALON_ENVTEST_ONLY_OLD".to_string()));
}
#[test]
fn new_key_wins_over_the_old_one_and_is_not_reported_as_used() {
let env = EnvFile::from_str_content("TALON_ENVTEST_BOTH=old\nTALAHON_ENVTEST_BOTH=new\n");
assert_eq!(env.file_value("TALAHON_ENVTEST_BOTH"), Some("new"));
assert_eq!(env.get("TALAHON_ENVTEST_BOTH").as_deref(), Some("new"));
assert!(!legacy_keys_used().contains(&"TALON_ENVTEST_BOTH".to_string()));
}
#[test]
fn an_old_key_is_noted_once_however_often_it_is_read() {
let mut log = LegacyKeyLog::default();
assert!(log.note("TALON_A"));
assert!(!log.note("TALON_A"));
assert!(log.note("TALON_B"));
assert_eq!(log.seen(), ["TALON_A", "TALON_B"]);
let env = EnvFile::from_str_content("TALON_ENVTEST_ONCE=v\n");
assert_eq!(env.file_value("TALAHON_ENVTEST_ONCE"), Some("v"));
assert!(!note_legacy_key("TALON_ENVTEST_ONCE"));
assert_eq!(env.get("TALAHON_ENVTEST_ONCE").as_deref(), Some("v"));
assert_eq!(
legacy_keys_used()
.iter()
.filter(|k| *k == "TALON_ENVTEST_ONCE")
.count(),
1
);
}
#[test]
fn resolve_with_prefers_new_then_old_and_ignores_foreign_prefixes() {
let vars: HashMap<&str, &str> = [
("TALON_ENVTEST_R1", "old"),
("TALAHON_ENVTEST_R2", "new"),
("TALON_ENVTEST_R2", "old"),
("COPILOT_X", "c"),
]
.into();
let lookup = |k: &str| vars.get(k).map(ToString::to_string);
assert_eq!(
resolve_with("TALAHON_ENVTEST_R1", lookup).as_deref(),
Some("old")
);
assert_eq!(
resolve_with("TALAHON_ENVTEST_R2", lookup).as_deref(),
Some("new")
);
assert_eq!(resolve_with("COPILOT_X", lookup).as_deref(), Some("c"));
assert_eq!(resolve_with("TALAHON_ENVTEST_R3", lookup), None);
}
#[test]
fn key_spellings_and_conversions() {
assert_eq!(legacy_key("TALAHON_LOG").as_deref(), Some("TALON_LOG"));
assert_eq!(legacy_key("TALON_LOG"), None);
assert_eq!(legacy_key("TALAHON_"), None);
assert_eq!(current_key("TALON_LOG").as_deref(), Some("TALAHON_LOG"));
assert_eq!(current_key("TALAHON_LOG"), None);
assert_eq!(current_key("HTTP_API_KEY"), None);
assert_eq!(
key_spellings("TALAHON_VLLM_API_KEY"),
["TALAHON_VLLM_API_KEY", "TALON_VLLM_API_KEY"]
);
assert_eq!(key_spellings("HTTP_API_KEY"), ["HTTP_API_KEY"]);
}
#[test]
fn legacy_keys_lists_old_spellings_and_conflicts() {
let env = EnvFile::from_str_content(concat!(
"TALON_VLLM_MODEL=old-model\n",
"TALAHON_VLLM_MODEL=new-model\n",
"TALON_AUDIT_KEY=k\n",
"TALAHON_AUDIT_KEY=k\n",
"TALON_LOG=debug\n",
"HTTP_API_KEY=x\n",
));
let process: HashMap<String, String> = [
("TALON_PROVIDER".to_string(), "vllm".to_string()),
("TALAHON_LOG".to_string(), "info".to_string()),
]
.into();
let found = env.legacy_keys_in(&process);
let brief: Vec<(&str, &str, EnvSource, bool)> = found
.iter()
.map(|k| (k.legacy.as_str(), k.current.as_str(), k.source, k.conflict))
.collect();
assert_eq!(
brief,
[
(
"TALON_AUDIT_KEY",
"TALAHON_AUDIT_KEY",
EnvSource::File,
false
),
("TALON_LOG", "TALAHON_LOG", EnvSource::File, true),
(
"TALON_PROVIDER",
"TALAHON_PROVIDER",
EnvSource::Process,
false
),
(
"TALON_VLLM_MODEL",
"TALAHON_VLLM_MODEL",
EnvSource::File,
true
),
]
);
}
#[test]
fn byte_order_mark_does_not_hide_the_first_key() {
let env = EnvFile::from_str_content("\u{feff}TZ=Europe/Berlin\nA=1\n");
assert_eq!(env.file_value("TZ"), Some("Europe/Berlin"));
}
}