1use std::collections::hash_map::DefaultHasher;
22use std::future::Future;
23use std::hash::{Hash, Hasher};
24use std::io;
25use std::path::{Path, PathBuf};
26use std::sync::RwLock;
27
28const ENV_INPUT_FILES: &[&str] = &[
32 ".envrc",
33 "mise.toml",
34 ".mise.toml",
35 ".tool-versions",
36 "pyvenv.cfg",
37 ".venv/pyvenv.cfg",
38];
39
40struct State {
41 snippet: String,
43 dir: Option<PathBuf>,
45 cache: Option<Cached>,
47}
48
49struct Cached {
50 inputs_hash: u64,
51 vars: Vec<(String, String)>,
52}
53
54pub struct EnvProvider {
56 state: RwLock<State>,
57 store: RwLock<Option<EnvStore>>,
62}
63
64impl EnvProvider {
65 pub fn inactive() -> Self {
67 Self {
68 state: RwLock::new(State {
69 snippet: String::new(),
70 dir: None,
71 cache: None,
72 }),
73 store: RwLock::new(None),
74 }
75 }
76
77 pub fn for_session(project_state_dir: &Path, trusted: bool) -> Self {
85 let p = Self::inactive();
86 p.set_store(Some(EnvStore::for_project_dir(project_state_dir)), trusted);
87 p
88 }
89
90 pub fn set_store(&self, store: Option<EnvStore>, trusted: bool) {
95 if trusted {
96 if let Some(store) = store.as_ref() {
97 if let Some((snippet, dir)) = store.recipe() {
98 if let Ok(mut s) = self.state.write() {
99 s.snippet = snippet;
100 s.dir = dir;
101 s.cache = None;
102 }
103 }
104 }
105 }
106 if let Ok(mut slot) = self.store.write() {
107 *slot = store;
108 }
109 }
110
111 pub fn set(&self, snippet: String, dir: Option<PathBuf>) {
114 if let Ok(mut s) = self.state.write() {
115 s.snippet = snippet.clone();
116 s.dir = dir.clone();
117 s.cache = None;
118 }
119 if let Ok(store) = self.store.read() {
120 if let Some(store) = store.as_ref() {
121 if snippet.trim().is_empty() {
122 store.remove();
123 } else if let Err(e) = store.record(&snippet, dir.as_deref()) {
124 tracing::warn!("env: failed to persist recipe: {e}");
125 }
126 }
127 }
128 }
129
130 pub fn clear(&self) {
133 if let Ok(mut s) = self.state.write() {
134 s.snippet.clear();
135 s.cache = None;
136 }
137 if let Ok(store) = self.store.read() {
138 if let Some(store) = store.as_ref() {
139 store.remove();
140 }
141 }
142 }
143
144 pub fn is_active(&self) -> bool {
146 self.state
147 .read()
148 .map(|s| !s.snippet.trim().is_empty())
149 .unwrap_or(false)
150 }
151
152 pub fn snippet(&self) -> String {
154 self.state
155 .read()
156 .map(|s| s.snippet.clone())
157 .unwrap_or_default()
158 }
159
160 pub async fn current<F, Fut>(&self, run: F) -> Vec<(String, String)>
168 where
169 F: FnOnce(String) -> Fut,
170 Fut: Future<Output = Option<String>>,
171 {
172 let (snippet, dir) = match self.state.read() {
173 Ok(s) => (s.snippet.clone(), s.dir.clone()),
174 Err(_) => return Vec::new(),
175 };
176 if snippet.trim().is_empty() {
177 return Vec::new();
178 }
179
180 let hash = inputs_hash(dir.as_deref());
181 if let Ok(s) = self.state.read() {
182 if let Some(c) = &s.cache {
183 if c.inputs_hash == hash {
184 return c.vars.clone();
185 }
186 }
187 }
188
189 let script = build_capture_script(&snippet, dir.as_deref());
190 let Some(stdout) = run(script).await else {
191 return Vec::new();
192 };
193 let vars = parse_env(&stdout);
194
195 if let Ok(mut s) = self.state.write() {
196 s.cache = Some(Cached {
197 inputs_hash: hash,
198 vars: vars.clone(),
199 });
200 }
201 vars
202 }
203}
204
205fn build_capture_script(snippet: &str, dir: Option<&Path>) -> String {
210 let mut script = String::new();
211 if let Some(d) = dir {
212 script.push_str("cd ");
213 script.push_str(&shell_quote(&d.to_string_lossy()));
214 script.push_str("; ");
215 }
216 let snippet = snippet.trim();
217 if !snippet.is_empty() {
218 script.push_str(snippet);
219 script.push_str("; ");
220 }
221 script.push_str("command env");
223 script
224}
225
226fn parse_env(stdout: &str) -> Vec<(String, String)> {
230 let mut out = Vec::new();
231 for line in stdout.lines() {
232 if let Some(eq) = line.find('=') {
233 if eq == 0 {
234 continue;
235 }
236 out.push((line[..eq].to_string(), line[eq + 1..].to_string()));
237 }
238 }
239 out
240}
241
242fn shell_quote(s: &str) -> String {
244 let mut out = String::with_capacity(s.len() + 2);
245 out.push('\'');
246 for c in s.chars() {
247 if c == '\'' {
248 out.push_str("'\\''");
249 } else {
250 out.push(c);
251 }
252 }
253 out.push('\'');
254 out
255}
256
257fn inputs_hash(dir: Option<&Path>) -> u64 {
262 let mut hasher = DefaultHasher::new();
263 if let Some(dir) = dir {
264 for name in ENV_INPUT_FILES {
265 let path = dir.join(name);
266 match std::fs::read(&path) {
267 Ok(bytes) => {
268 name.hash(&mut hasher);
269 bytes.hash(&mut hasher);
270 }
271 Err(_) => {
272 name.hash(&mut hasher);
274 0u8.hash(&mut hasher);
275 }
276 }
277 }
278 }
279 hasher.finish()
280}
281
282#[derive(serde::Serialize, serde::Deserialize)]
284struct StoredEnv {
285 snippet: String,
286 #[serde(default)]
287 dir: Option<PathBuf>,
288}
289
290#[derive(Debug, Clone)]
298pub struct EnvStore {
299 path: PathBuf,
300}
301
302impl EnvStore {
303 pub fn for_project_dir(project_state_dir: &Path) -> Self {
305 Self {
306 path: project_state_dir.join("env.json"),
307 }
308 }
309
310 fn recipe(&self) -> Option<(String, Option<PathBuf>)> {
313 let text = std::fs::read_to_string(&self.path).ok()?;
314 let stored: StoredEnv = serde_json::from_str(&text).ok()?;
315 if stored.snippet.trim().is_empty() {
316 return None;
317 }
318 Some((stored.snippet, stored.dir))
319 }
320
321 fn record(&self, snippet: &str, dir: Option<&Path>) -> io::Result<()> {
324 if let Some(parent) = self.path.parent() {
325 std::fs::create_dir_all(parent)?;
326 }
327 let json = serde_json::to_string_pretty(&StoredEnv {
328 snippet: snippet.to_string(),
329 dir: dir.map(Path::to_path_buf),
330 })
331 .map_err(io::Error::other)?;
332 let tmp = self
333 .path
334 .with_extension(format!("json.{}.tmp", std::process::id()));
335 std::fs::write(&tmp, json.as_bytes())?;
336 std::fs::rename(&tmp, &self.path)?;
337 Ok(())
338 }
339
340 fn remove(&self) {
344 if let Err(e) = std::fs::remove_file(&self.path) {
345 if e.kind() != std::io::ErrorKind::NotFound {
346 tracing::warn!("env: failed to remove recipe: {e}");
347 }
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn inactive_by_default_and_after_clear() {
358 let p = EnvProvider::inactive();
359 assert!(!p.is_active());
360 p.set(
361 "source .venv/bin/activate".into(),
362 Some(PathBuf::from("/proj")),
363 );
364 assert!(p.is_active());
365 assert_eq!(p.snippet(), "source .venv/bin/activate");
366 p.clear();
367 assert!(!p.is_active());
368 }
369
370 #[test]
371 fn whitespace_snippet_is_inactive() {
372 let p = EnvProvider::inactive();
373 p.set(" \n ".into(), None);
374 assert!(!p.is_active());
375 }
376
377 #[test]
378 fn build_capture_script_shapes() {
379 assert_eq!(
380 build_capture_script("source .venv/bin/activate", Some(Path::new("/a b"))),
381 "cd '/a b'; source .venv/bin/activate; command env"
382 );
383 assert_eq!(build_capture_script("", None), "command env");
384 assert_eq!(
385 build_capture_script(r#"eval "$(direnv export bash)""#, None),
386 r#"eval "$(direnv export bash)"; command env"#
387 );
388 }
389
390 #[test]
391 fn parse_env_basics() {
392 let out = "PATH=/a:/b\nVIRTUAL_ENV=/p/.venv\nWEIRD=a=b=c\n=skipme\nnoeq\n";
393 let vars = parse_env(out);
394 assert_eq!(
395 vars,
396 vec![
397 ("PATH".to_string(), "/a:/b".to_string()),
398 ("VIRTUAL_ENV".to_string(), "/p/.venv".to_string()),
399 ("WEIRD".to_string(), "a=b=c".to_string()),
400 ]
401 );
402 }
403
404 #[tokio::test]
405 async fn current_inactive_returns_empty_without_running() {
406 let p = EnvProvider::inactive();
407 let ran = std::cell::Cell::new(false);
408 let vars = p
409 .current(|_script| {
410 ran.set(true);
411 async { Some("X=1".to_string()) }
412 })
413 .await;
414 assert!(vars.is_empty());
415 assert!(!ran.get(), "capture must not run when inactive");
416 }
417
418 #[tokio::test]
419 async fn current_captures_and_caches() {
420 let tmp = tempfile::tempdir().unwrap();
421 let p = EnvProvider::inactive();
422 p.set("true".into(), Some(tmp.path().to_path_buf()));
423
424 let calls = std::cell::Cell::new(0);
425 let run = || {
426 calls.set(calls.get() + 1);
427 async { Some("FOO=bar\nPATH=/x\n".to_string()) }
428 };
429
430 let v1 = p.current(|_s| run()).await;
431 assert_eq!(
432 v1,
433 vec![("FOO".into(), "bar".into()), ("PATH".into(), "/x".into())]
434 );
435 let v2 = p.current(|_s| run()).await;
437 assert_eq!(v2, v1);
438 assert_eq!(calls.get(), 1, "cache should prevent a second capture");
439 }
440
441 #[tokio::test]
442 async fn cache_invalidated_when_inputs_change() {
443 let tmp = tempfile::tempdir().unwrap();
444 let p = EnvProvider::inactive();
445 p.set("true".into(), Some(tmp.path().to_path_buf()));
446
447 let n = std::cell::Cell::new(0);
448 let v1 = p
449 .current(|_s| {
450 n.set(n.get() + 1);
451 async move { Some("A=1".to_string()) }
452 })
453 .await;
454 assert_eq!(v1, vec![("A".into(), "1".into())]);
455
456 std::fs::write(tmp.path().join(".envrc"), "export A=2\n").unwrap();
458 let v2 = p
459 .current(|_s| {
460 n.set(n.get() + 1);
461 async move { Some("A=2".to_string()) }
462 })
463 .await;
464 assert_eq!(v2, vec![("A".into(), "2".into())]);
465 assert_eq!(n.get(), 2, "input change should force a re-capture");
466 }
467
468 #[tokio::test]
469 async fn capture_failure_degrades_to_empty() {
470 let p = EnvProvider::inactive();
471 p.set("true".into(), None);
472 let vars = p.current(|_s| async { None }).await;
473 assert!(vars.is_empty());
474 }
475
476 #[test]
477 fn for_session_restores_a_persisted_recipe_when_trusted() {
478 let tmp = tempfile::tempdir().unwrap();
479 let first = EnvProvider::for_session(tmp.path(), true);
481 first.set(
482 "eval \"$(direnv export bash)\"".into(),
483 Some(PathBuf::from("/proj")),
484 );
485 assert!(first.is_active());
486
487 let next = EnvProvider::for_session(tmp.path(), true);
490 assert!(next.is_active());
491 assert_eq!(next.snippet(), "eval \"$(direnv export bash)\"");
492 }
493
494 #[test]
495 fn for_session_does_not_restore_when_untrusted() {
496 let tmp = tempfile::tempdir().unwrap();
497 EnvProvider::for_session(tmp.path(), true).set("true".into(), None);
498 let untrusted = EnvProvider::for_session(tmp.path(), false);
501 assert!(!untrusted.is_active());
502 }
503
504 #[test]
505 fn clear_forgets_the_persisted_recipe() {
506 let tmp = tempfile::tempdir().unwrap();
507 let p = EnvProvider::for_session(tmp.path(), true);
508 p.set("true".into(), None);
509 p.clear();
510 let next = EnvProvider::for_session(tmp.path(), true);
512 assert!(!next.is_active());
513 }
514
515 #[test]
516 fn inactive_provider_never_persists() {
517 let tmp = tempfile::tempdir().unwrap();
518 let p = EnvProvider::inactive();
520 p.set("true".into(), None);
521 assert!(EnvStore::for_project_dir(tmp.path()).recipe().is_none());
522 }
523}