Skip to main content

fresh/services/
env_provider.rs

1//! Live environment provider.
2//!
3//! The active environment is a **recipe** — a shell snippet plus the project
4//! directory — not a stored snapshot. It is re-evaluated on demand by running
5//! the snippet on the active backend's host and capturing the resulting
6//! environment, so it can never go stale. A content-hash cache over the env
7//! inputs (`.envrc`, `mise.toml`, …) keeps the common path free without making
8//! correctness depend on the cache.
9//!
10//! Shared and interior-mutable, exactly like
11//! [`WorkspaceTrust`](crate::services::workspace_trust::WorkspaceTrust): every
12//! spawner holds the same `Arc<EnvProvider>`, the plugin sets the recipe in
13//! place via `editor.setEnv` / `clearEnv`, and there is no authority rebuild.
14//!
15//! The provider is backend-agnostic: [`EnvProvider::current`] builds the
16//! capture *script* and hands it to a caller-supplied `run` closure that
17//! actually executes it on the right host (local tokio / SSH / docker). That
18//! closure must run a **raw** spawn that does not itself apply this provider's
19//! env — otherwise capturing the env would recurse.
20
21use 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
28/// Files whose contents define a project's environment. Used to key the
29/// capture cache: if none changed, the captured env can't have changed (for
30/// the common, file-driven managers).
31const 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    /// The activation snippet. Empty/whitespace ⇒ inactive (no env applied).
42    snippet: String,
43    /// Project directory the snippet runs in.
44    dir: Option<PathBuf>,
45    /// Last capture, keyed by the env-inputs hash it was produced under.
46    cache: Option<Cached>,
47}
48
49struct Cached {
50    inputs_hash: u64,
51    vars: Vec<(String, String)>,
52}
53
54/// Shared, live environment recipe.
55pub struct EnvProvider {
56    state: RwLock<State>,
57    /// Per-project recipe store. When set, [`Self::set`] / [`Self::clear`]
58    /// write the recipe through to disk so the next launch can boot already
59    /// in this env (no post-boot `setEnv` → restart flicker, issue #2280).
60    /// `None` for placeholder / non-persistent providers (remote stubs, tests).
61    store: RwLock<Option<EnvStore>>,
62}
63
64impl EnvProvider {
65    /// An inactive provider — no snippet, applies no env, no persistence.
66    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    /// A per-session provider backed by this project's recipe store. When
78    /// `trusted`, any recipe the user previously activated for the project is
79    /// restored immediately, so the session boots already in its env and
80    /// tooling spawns under it from frame zero — there is no auto-activation
81    /// restart. An untrusted session restores nothing (the env gate mirrors
82    /// the spawn gate); a later trust + activate persists through the store
83    /// and is picked up on the next launch.
84    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    /// Attach (or replace) the recipe store. When `trusted` and the store has
91    /// a recorded recipe, it is adopted as the live recipe — this is how a
92    /// boot session re-enters its env once the working dir + trust level are
93    /// known (mirrors `WorkspaceTrust::set_store` adopting the persisted level).
94    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    /// Set the active recipe (activation). Invalidates the cache and, when a
112    /// store is attached, persists the recipe for the next launch.
113    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    /// Deactivate — drop the snippet so no env is applied, and forget the
131    /// persisted recipe so the next launch boots clean.
132    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    /// Whether an environment is currently active.
145    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    /// The current activation snippet (for status / inspection).
153    pub fn snippet(&self) -> String {
154        self.state
155            .read()
156            .map(|s| s.snippet.clone())
157            .unwrap_or_default()
158    }
159
160    /// Resolve the active environment, capturing fresh when the env inputs have
161    /// changed since the last capture. Returns an empty vec when inactive or
162    /// when capture fails (degrade to the inherited env).
163    ///
164    /// `run` executes the capture *script* on the active host and returns its
165    /// stdout (`None` on failure). It MUST be a raw spawn that does not apply
166    /// this provider's env, or capture would recurse.
167    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
205/// Build the shell script the capture runs: `cd <dir>; <snippet>; command env`.
206/// The caller's `run` closure wraps this in the host's shell (`$SHELL -lc …`
207/// locally, `ssh … sh -lc …` remotely, etc.). `command env` prints the
208/// resulting environment, one `KEY=VALUE` per line.
209fn 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    // `command env` bypasses any `env` function/alias.
222    script.push_str("command env");
223    script
224}
225
226/// Parse `env` output (`KEY=VALUE` lines) into pairs. Lines without `=` or with
227/// an empty key are skipped. A value may itself contain `=`; only the first is
228/// the separator. (Values with embedded newlines — rare — are not handled.)
229fn 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
242/// POSIX single-quote escaping for splicing a path into a shell command.
243fn 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
257/// Hash the env-input files under `dir` (existence + contents). The capture
258/// cache is valid only while this is unchanged. Best-effort: unreadable files
259/// hash as absent. `None` dir (or no inputs) yields a stable hash so a
260/// snippet-only recipe still caches.
261fn 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                    // record absence distinctly from "present and empty"
273                    name.hash(&mut hasher);
274                    0u8.hash(&mut hasher);
275                }
276            }
277        }
278    }
279    hasher.finish()
280}
281
282/// On-disk record of a project's activated env recipe.
283#[derive(serde::Serialize, serde::Deserialize)]
284struct StoredEnv {
285    snippet: String,
286    #[serde(default)]
287    dir: Option<PathBuf>,
288}
289
290/// Per-project persistence of the *activated env recipe* — the recipe the user
291/// last activated for a project, so the next launch can boot directly into it.
292///
293/// One file per project (`env.json`), alongside `trust.json` in the project's
294/// state dir (see `DirectoryContext::project_state_dir`), never inside the
295/// repository. Presence of a recipe *is* the "this project's env is activated"
296/// decision; restoring it is gated on trust exactly like a spawn.
297#[derive(Debug, Clone)]
298pub struct EnvStore {
299    path: PathBuf,
300}
301
302impl EnvStore {
303    /// Recipe file for the project whose state lives in `project_state_dir`.
304    pub fn for_project_dir(project_state_dir: &Path) -> Self {
305        Self {
306            path: project_state_dir.join("env.json"),
307        }
308    }
309
310    /// The recorded recipe (`snippet`, `dir`), or `None` when absent, empty, or
311    /// corrupt (a corrupt file reads as "no recipe"; the next write rewrites it).
312    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    /// Record the activated recipe, written atomically (pid-tagged temp then
322    /// rename) so a half-written file is never observed.
323    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    /// Forget the recipe (deactivation). A missing file is success; any other
341    /// error is logged best-effort (the result is `#[must_use]`, so it is
342    /// handled rather than discarded — the crate denies `let_underscore_must_use`).
343    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        // Second call with unchanged inputs hits the cache — no re-run.
436        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        // Change an env input → cache must miss and re-capture.
457        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        // First session: trusted, user activates → recipe persists.
480        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        // Next launch: a fresh trusted session boots already in the env, with
488        // no plugin re-activation needed — this is what removes the flicker.
489        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        // An untrusted session must not silently re-enter a persisted env —
499        // the env gate mirrors the spawn gate.
500        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        // After deactivation the next launch boots clean.
511        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        // A storeless provider applies env in-memory but writes nothing.
519        let p = EnvProvider::inactive();
520        p.set("true".into(), None);
521        assert!(EnvStore::for_project_dir(tmp.path()).recipe().is_none());
522    }
523}