use crate::services::process_hidden::HideWindow;
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>,
delta_cache: Option<CachedDelta>,
}
struct Cached {
inputs_hash: u64,
vars: Vec<(String, String)>,
}
struct CachedDelta {
inputs_hash: u64,
delta: EnvDelta,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvDelta {
pub set: Vec<(String, String)>,
pub unset: Vec<String>,
}
impl EnvDelta {
pub fn is_empty(&self) -> bool {
self.set.is_empty() && self.unset.is_empty()
}
}
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,
delta_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;
s.delta_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;
s.delta_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;
s.delta_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
}
pub fn current_local_delta_blocking(&self) -> EnvDelta {
self.capture_delta_blocking(|script| {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let output = std::process::Command::new(&shell)
.arg("-lc")
.arg(&script)
.hide_window()
.output()
.ok()?;
Some(String::from_utf8_lossy(&output.stdout).into_owned())
})
}
fn capture_delta_blocking(&self, run: impl FnOnce(String) -> Option<String>) -> EnvDelta {
let (snippet, dir) = match self.state.read() {
Ok(s) => (s.snippet.clone(), s.dir.clone()),
Err(_) => return EnvDelta::default(),
};
if snippet.trim().is_empty() {
return EnvDelta::default();
}
let hash = inputs_hash(dir.as_deref());
if let Ok(s) = self.state.read() {
if let Some(c) = &s.delta_cache {
if c.inputs_hash == hash {
return c.delta.clone();
}
}
}
let script = build_delta_capture_script(&snippet, dir.as_deref());
let Some(stdout) = run(script) else {
return EnvDelta::default();
};
let delta = parse_delta(&stdout);
if let Ok(mut s) = self.state.write() {
s.delta_cache = Some(CachedDelta {
inputs_hash: hash,
delta: delta.clone(),
});
}
delta
}
}
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
}
pub(crate) const DELTA_SENTINEL: &str = "__FRESH_ENV_DELTA_SENTINEL_b3f1c2__";
fn build_delta_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("; ");
}
script.push_str("command env; printf '%s\\n' ");
script.push_str(&shell_quote(DELTA_SENTINEL));
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_delta(stdout: &str) -> EnvDelta {
let Some((baseline_text, activated_text)) = stdout.split_once(DELTA_SENTINEL) else {
return EnvDelta::default();
};
let baseline = parse_env(baseline_text);
let activated = parse_env(activated_text);
let baseline_lookup: std::collections::HashMap<&str, &str> = baseline
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut set = Vec::new();
for (k, v) in &activated {
if baseline_lookup.get(k.as_str()) != Some(&v.as_str()) {
set.push((k.clone(), v.clone()));
}
}
let activated_keys: std::collections::HashSet<&str> =
activated.iter().map(|(k, _)| k.as_str()).collect();
let unset = baseline
.iter()
.filter(|(k, _)| !activated_keys.contains(k.as_str()))
.map(|(k, _)| k.clone())
.collect();
EnvDelta { set, unset }
}
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 build_delta_capture_script_shape() {
let s = build_delta_capture_script("source .venv/bin/activate", Some(Path::new("/p")));
assert_eq!(
s,
format!(
"cd '/p'; command env; printf '%s\\n' '{DELTA_SENTINEL}'; \
source .venv/bin/activate; 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");
}
fn delta_output(baseline: &str, activated: &str) -> String {
format!("{baseline}\n{DELTA_SENTINEL}\n{activated}")
}
#[test]
fn parse_delta_extracts_added_changed_and_removed() {
let out = delta_output(
"HOME=/h\nPATH=/usr/bin\nGONE=1\nSHLVL=3",
"HOME=/h\nPATH=/p/.venv/bin:/usr/bin\nVIRTUAL_ENV=/p/.venv\nSHLVL=3",
);
let d = parse_delta(&out);
assert_eq!(
d.set,
vec![
("PATH".into(), "/p/.venv/bin:/usr/bin".into()),
("VIRTUAL_ENV".into(), "/p/.venv".into()),
]
);
assert_eq!(d.unset, vec!["GONE".to_string()]);
}
#[test]
fn parse_delta_missing_sentinel_is_empty() {
assert!(parse_delta("HOME=/h\nPATH=/usr/bin").is_empty());
}
#[test]
fn delta_capture_inactive_returns_empty_without_running() {
let p = EnvProvider::inactive();
let ran = std::cell::Cell::new(false);
let d = p.capture_delta_blocking(|_script| {
ran.set(true);
Some(delta_output("A=1", "A=1\nB=2"))
});
assert!(d.is_empty());
assert!(!ran.get(), "delta capture must not run when inactive");
}
#[test]
fn delta_capture_caches_on_unchanged_inputs() {
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);
Some(delta_output("PATH=/usr/bin", "PATH=/usr/bin\nFOO=bar"))
};
let d1 = p.capture_delta_blocking(|_s| run());
assert_eq!(d1.set, vec![("FOO".into(), "bar".into())]);
let d2 = p.capture_delta_blocking(|_s| run());
assert_eq!(d2, d1);
assert_eq!(calls.get(), 1, "warm cache should prevent a second 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());
}
}