use std::collections::hash_map::DefaultHasher;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
const ENV_INPUT_FILES: &[&str] = &[
".envrc",
"mise.toml",
".mise.toml",
".tool-versions",
"pyvenv.cfg",
".venv/pyvenv.cfg",
];
struct State {
snippet: String,
dir: Option<PathBuf>,
cache: Option<Cached>,
}
struct Cached {
inputs_hash: u64,
vars: Vec<(String, String)>,
}
pub struct EnvProvider {
state: RwLock<State>,
store: RwLock<Option<EnvStore>>,
}
impl EnvProvider {
pub fn inactive() -> Self {
Self {
state: RwLock::new(State {
snippet: String::new(),
dir: None,
cache: None,
}),
store: RwLock::new(None),
}
}
pub fn for_session(project_state_dir: &Path, trusted: bool) -> Self {
let p = Self::inactive();
p.set_store(Some(EnvStore::for_project_dir(project_state_dir)), trusted);
p
}
pub fn set_store(&self, store: Option<EnvStore>, trusted: bool) {
if trusted {
if let Some(store) = store.as_ref() {
if let Some((snippet, dir)) = store.recipe() {
if let Ok(mut s) = self.state.write() {
s.snippet = snippet;
s.dir = dir;
s.cache = None;
}
}
}
}
if let Ok(mut slot) = self.store.write() {
*slot = store;
}
}
pub fn set(&self, snippet: String, dir: Option<PathBuf>) {
if let Ok(mut s) = self.state.write() {
s.snippet = snippet.clone();
s.dir = dir.clone();
s.cache = None;
}
if let Ok(store) = self.store.read() {
if let Some(store) = store.as_ref() {
if snippet.trim().is_empty() {
store.remove();
} else if let Err(e) = store.record(&snippet, dir.as_deref()) {
tracing::warn!("env: failed to persist recipe: {e}");
}
}
}
}
pub fn clear(&self) {
if let Ok(mut s) = self.state.write() {
s.snippet.clear();
s.cache = None;
}
if let Ok(store) = self.store.read() {
if let Some(store) = store.as_ref() {
store.remove();
}
}
}
pub fn is_active(&self) -> bool {
self.state
.read()
.map(|s| !s.snippet.trim().is_empty())
.unwrap_or(false)
}
pub fn snippet(&self) -> String {
self.state
.read()
.map(|s| s.snippet.clone())
.unwrap_or_default()
}
pub async fn current<F, Fut>(&self, run: F) -> Vec<(String, String)>
where
F: FnOnce(String) -> Fut,
Fut: Future<Output = Option<String>>,
{
let (snippet, dir) = match self.state.read() {
Ok(s) => (s.snippet.clone(), s.dir.clone()),
Err(_) => return Vec::new(),
};
if snippet.trim().is_empty() {
return Vec::new();
}
let hash = inputs_hash(dir.as_deref());
if let Ok(s) = self.state.read() {
if let Some(c) = &s.cache {
if c.inputs_hash == hash {
return c.vars.clone();
}
}
}
let script = build_capture_script(&snippet, dir.as_deref());
let Some(stdout) = run(script).await else {
return Vec::new();
};
let vars = parse_env(&stdout);
if let Ok(mut s) = self.state.write() {
s.cache = Some(Cached {
inputs_hash: hash,
vars: vars.clone(),
});
}
vars
}
}
fn build_capture_script(snippet: &str, dir: Option<&Path>) -> String {
let mut script = String::new();
if let Some(d) = dir {
script.push_str("cd ");
script.push_str(&shell_quote(&d.to_string_lossy()));
script.push_str("; ");
}
let snippet = snippet.trim();
if !snippet.is_empty() {
script.push_str(snippet);
script.push_str("; ");
}
script.push_str("command env");
script
}
fn parse_env(stdout: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for line in stdout.lines() {
if let Some(eq) = line.find('=') {
if eq == 0 {
continue;
}
out.push((line[..eq].to_string(), line[eq + 1..].to_string()));
}
}
out
}
fn shell_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
fn inputs_hash(dir: Option<&Path>) -> u64 {
let mut hasher = DefaultHasher::new();
if let Some(dir) = dir {
for name in ENV_INPUT_FILES {
let path = dir.join(name);
match std::fs::read(&path) {
Ok(bytes) => {
name.hash(&mut hasher);
bytes.hash(&mut hasher);
}
Err(_) => {
name.hash(&mut hasher);
0u8.hash(&mut hasher);
}
}
}
}
hasher.finish()
}
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredEnv {
snippet: String,
#[serde(default)]
dir: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct EnvStore {
path: PathBuf,
}
impl EnvStore {
pub fn for_project_dir(project_state_dir: &Path) -> Self {
Self {
path: project_state_dir.join("env.json"),
}
}
fn recipe(&self) -> Option<(String, Option<PathBuf>)> {
let text = std::fs::read_to_string(&self.path).ok()?;
let stored: StoredEnv = serde_json::from_str(&text).ok()?;
if stored.snippet.trim().is_empty() {
return None;
}
Some((stored.snippet, stored.dir))
}
fn record(&self, snippet: &str, dir: Option<&Path>) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&StoredEnv {
snippet: snippet.to_string(),
dir: dir.map(Path::to_path_buf),
})
.map_err(io::Error::other)?;
let tmp = self
.path
.with_extension(format!("json.{}.tmp", std::process::id()));
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
fn remove(&self) {
if let Err(e) = std::fs::remove_file(&self.path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!("env: failed to remove recipe: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inactive_by_default_and_after_clear() {
let p = EnvProvider::inactive();
assert!(!p.is_active());
p.set(
"source .venv/bin/activate".into(),
Some(PathBuf::from("/proj")),
);
assert!(p.is_active());
assert_eq!(p.snippet(), "source .venv/bin/activate");
p.clear();
assert!(!p.is_active());
}
#[test]
fn whitespace_snippet_is_inactive() {
let p = EnvProvider::inactive();
p.set(" \n ".into(), None);
assert!(!p.is_active());
}
#[test]
fn build_capture_script_shapes() {
assert_eq!(
build_capture_script("source .venv/bin/activate", Some(Path::new("/a b"))),
"cd '/a b'; source .venv/bin/activate; command env"
);
assert_eq!(build_capture_script("", None), "command env");
assert_eq!(
build_capture_script(r#"eval "$(direnv export bash)""#, None),
r#"eval "$(direnv export bash)"; command env"#
);
}
#[test]
fn parse_env_basics() {
let out = "PATH=/a:/b\nVIRTUAL_ENV=/p/.venv\nWEIRD=a=b=c\n=skipme\nnoeq\n";
let vars = parse_env(out);
assert_eq!(
vars,
vec![
("PATH".to_string(), "/a:/b".to_string()),
("VIRTUAL_ENV".to_string(), "/p/.venv".to_string()),
("WEIRD".to_string(), "a=b=c".to_string()),
]
);
}
#[tokio::test]
async fn current_inactive_returns_empty_without_running() {
let p = EnvProvider::inactive();
let ran = std::cell::Cell::new(false);
let vars = p
.current(|_script| {
ran.set(true);
async { Some("X=1".to_string()) }
})
.await;
assert!(vars.is_empty());
assert!(!ran.get(), "capture must not run when inactive");
}
#[tokio::test]
async fn current_captures_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let p = EnvProvider::inactive();
p.set("true".into(), Some(tmp.path().to_path_buf()));
let calls = std::cell::Cell::new(0);
let run = || {
calls.set(calls.get() + 1);
async { Some("FOO=bar\nPATH=/x\n".to_string()) }
};
let v1 = p.current(|_s| run()).await;
assert_eq!(
v1,
vec![("FOO".into(), "bar".into()), ("PATH".into(), "/x".into())]
);
let v2 = p.current(|_s| run()).await;
assert_eq!(v2, v1);
assert_eq!(calls.get(), 1, "cache should prevent a second capture");
}
#[tokio::test]
async fn cache_invalidated_when_inputs_change() {
let tmp = tempfile::tempdir().unwrap();
let p = EnvProvider::inactive();
p.set("true".into(), Some(tmp.path().to_path_buf()));
let n = std::cell::Cell::new(0);
let v1 = p
.current(|_s| {
n.set(n.get() + 1);
async move { Some("A=1".to_string()) }
})
.await;
assert_eq!(v1, vec![("A".into(), "1".into())]);
std::fs::write(tmp.path().join(".envrc"), "export A=2\n").unwrap();
let v2 = p
.current(|_s| {
n.set(n.get() + 1);
async move { Some("A=2".to_string()) }
})
.await;
assert_eq!(v2, vec![("A".into(), "2".into())]);
assert_eq!(n.get(), 2, "input change should force a re-capture");
}
#[tokio::test]
async fn capture_failure_degrades_to_empty() {
let p = EnvProvider::inactive();
p.set("true".into(), None);
let vars = p.current(|_s| async { None }).await;
assert!(vars.is_empty());
}
#[test]
fn for_session_restores_a_persisted_recipe_when_trusted() {
let tmp = tempfile::tempdir().unwrap();
let first = EnvProvider::for_session(tmp.path(), true);
first.set(
"eval \"$(direnv export bash)\"".into(),
Some(PathBuf::from("/proj")),
);
assert!(first.is_active());
let next = EnvProvider::for_session(tmp.path(), true);
assert!(next.is_active());
assert_eq!(next.snippet(), "eval \"$(direnv export bash)\"");
}
#[test]
fn for_session_does_not_restore_when_untrusted() {
let tmp = tempfile::tempdir().unwrap();
EnvProvider::for_session(tmp.path(), true).set("true".into(), None);
let untrusted = EnvProvider::for_session(tmp.path(), false);
assert!(!untrusted.is_active());
}
#[test]
fn clear_forgets_the_persisted_recipe() {
let tmp = tempfile::tempdir().unwrap();
let p = EnvProvider::for_session(tmp.path(), true);
p.set("true".into(), None);
p.clear();
let next = EnvProvider::for_session(tmp.path(), true);
assert!(!next.is_active());
}
#[test]
fn inactive_provider_never_persists() {
let tmp = tempfile::tempdir().unwrap();
let p = EnvProvider::inactive();
p.set("true".into(), None);
assert!(EnvStore::for_project_dir(tmp.path()).recipe().is_none());
}
}