Skip to main content

xtask_todo_lib/devshell/vm/
config.rs

1//! Environment-driven configuration for optional devshell VM execution (`DEVSHELL_VM`, backend, Lima name).
2
3#![allow(clippy::pedantic, clippy::nursery)]
4
5/// `DEVSHELL_VM` — **Release / binary default:** unset means **on** (use VM backend per [`ENV_DEVSHELL_VM_BACKEND`]).
6/// Set to `off` / `0` / `false` / `no` (case-insensitive) to use **only** the host temp sandbox.
7/// `on` / `1` / `true` / `yes` also enable VM mode.
8///
9/// **Unit tests** (`cfg(test)`): unset defaults to **off** so `cargo test` works without Lima.
10pub const ENV_DEVSHELL_VM: &str = "DEVSHELL_VM";
11
12/// Backend selector: `host`, `auto`, `lima`, `beta`, …
13///
14/// **Release / binary default on Unix:** `lima` (γ) when this variable is unset.
15/// **Windows** default: **`beta`** (with **`beta-vm`** feature). Use **`DEVSHELL_VM_BACKEND=host`** for host-only sandbox.
16/// **Other non-Unix (non-Windows):** `host`.
17/// **`cfg(test)`:** unset → `auto` (host sandbox) for the same reason as `ENV_DEVSHELL_VM`.
18pub const ENV_DEVSHELL_VM_BACKEND: &str = "DEVSHELL_VM_BACKEND";
19
20/// When `1`/`true`/`yes`, start the VM session eagerly (future γ); default is lazy start on first rust tool.
21pub const ENV_DEVSHELL_VM_EAGER: &str = "DEVSHELL_VM_EAGER";
22
23/// Lima instance name for γ (`limactl shell <name>`).
24pub const ENV_DEVSHELL_VM_LIMA_INSTANCE: &str = "DEVSHELL_VM_LIMA_INSTANCE";
25
26/// Unix socket path for β client ↔ `devshell-vm --serve-socket` (see IPC draft).
27pub const ENV_DEVSHELL_VM_SOCKET: &str = "DEVSHELL_VM_SOCKET";
28
29/// When set (non-empty), β **`session_start`** sends this string as **`staging_dir`** to the sidecar instead of
30/// `canonicalize(DEVSHELL_VM_WORKSPACE_PARENT / …)`. Use a **POSIX path** visible to the sidecar process
31/// (e.g. **`/workspace`** inside a Podman/WSL Linux container) while **`DEVSHELL_VM_WORKSPACE_PARENT`** on the
32/// host remains the real Windows path for push/pull. See **`docs/devshell-vm-windows.md`** (Podman).
33///
34/// On Windows, if **`cargo-devshell`** starts the Podman sidecar itself, it sets this to **`/workspace`** for
35/// that process (unless you already exported a value).
36pub const ENV_DEVSHELL_VM_BETA_SESSION_STAGING: &str = "DEVSHELL_VM_BETA_SESSION_STAGING";
37
38/// When set (any value), skip automatic Podman image build / `podman run` on Windows (tests or manual sidecar).
39pub const ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP: &str = "DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP";
40
41/// When set (any value), do **not** isolate **`USERPROFILE` / `HOME`** for `podman` subprocesses (Windows).
42/// By default we point **`USERPROFILE`** (Go’s `UserHomeDir()` on Windows — not only `HOME`) at a writable
43/// temp “profile” with an **empty default** `.ssh/known_hosts`, so a **locked, protected, or invalid**
44/// **`%USERPROFILE%\.ssh\known_hosts`** is not read. An existing Podman Machine dir is **symlinked** in when
45/// possible (see `podman_sidecar.rs`).
46pub const ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME: &str = "DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME";
47
48/// `DEVSHELL_VM_WORKSPACE_MODE` — **`sync`** (default) or **`guest`** (Mode P; guest filesystem as source of truth).
49///
50/// **`guest`** is effective only when the VM is enabled and the backend is **`lima`** or **`beta`**; otherwise
51/// [`VmConfig::workspace_mode_effective`] returns [`WorkspaceMode::Sync`] (design `2026-03-20-devshell-guest-primary-design.md` §6).
52///
53/// **Unset** (including **`cfg(test)`**): [`WorkspaceMode::Sync`].
54pub const ENV_DEVSHELL_VM_WORKSPACE_MODE: &str = "DEVSHELL_VM_WORKSPACE_MODE";
55
56/// How the devshell workspace is backed: memory VFS + push/pull (**[`WorkspaceMode::Sync`]**) vs guest-primary (**[`WorkspaceMode::Guest`]**, planned).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum WorkspaceMode {
59    /// Mode S: in-memory `Vfs` authority; `cargo`/`rustup` sync with guest when using γ.
60    Sync,
61    /// Mode P: guest mount is the source of truth for the project tree (incremental implementation).
62    Guest,
63}
64
65/// Read [`ENV_DEVSHELL_VM_WORKSPACE_MODE`] from the environment.
66#[must_use]
67pub fn workspace_mode_from_env() -> WorkspaceMode {
68    match std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE) {
69        Ok(s) if s.trim().eq_ignore_ascii_case("guest") => WorkspaceMode::Guest,
70        _ => WorkspaceMode::Sync,
71    }
72}
73
74/// Parsed VM-related environment.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct VmConfig {
77    /// `DEVSHELL_VM` enabled.
78    pub enabled: bool,
79    /// Raw backend string (trimmed); see `ENV_DEVSHELL_VM_BACKEND` for defaults.
80    pub backend: String,
81    /// Eager VM/session start when REPL opens (vs lazy on first `rustup`/`cargo`).
82    pub eager_start: bool,
83    /// Lima instance name.
84    pub lima_instance: String,
85}
86
87fn truthy(s: &str) -> bool {
88    let s = s.trim();
89    s == "1"
90        || s.eq_ignore_ascii_case("true")
91        || s.eq_ignore_ascii_case("yes")
92        || s.eq_ignore_ascii_case("on")
93}
94
95fn falsy(s: &str) -> bool {
96    let s = s.trim();
97    s == "0"
98        || s.eq_ignore_ascii_case("false")
99        || s.eq_ignore_ascii_case("no")
100        || s.eq_ignore_ascii_case("off")
101}
102
103/// Walk parents from [`std::env::current_dir`] looking for `containers/devshell-vm/Containerfile` (xtask_todo repo).
104#[cfg(all(windows, feature = "beta-vm"))]
105pub(crate) fn devshell_repo_root_with_containerfile() -> Option<std::path::PathBuf> {
106    let mut dir = std::env::current_dir().ok()?;
107    loop {
108        let cf = dir.join("containers/devshell-vm/Containerfile");
109        if cf.is_file() {
110            return Some(dir);
111        }
112        if !dir.pop() {
113            break;
114        }
115    }
116    None
117}
118
119fn default_backend_for_release() -> String {
120    #[cfg(all(windows, feature = "beta-vm"))]
121    {
122        return "beta".to_string();
123    }
124    #[cfg(unix)]
125    {
126        "lima".to_string()
127    }
128    #[cfg(not(any(unix, all(windows, feature = "beta-vm"))))]
129    {
130        "host".to_string()
131    }
132}
133
134fn vm_enabled_from_env() -> bool {
135    if cfg!(test) {
136        return std::env::var(ENV_DEVSHELL_VM)
137            .map(|s| truthy(&s))
138            .unwrap_or(false);
139    }
140    match std::env::var(ENV_DEVSHELL_VM) {
141        Err(_) => true,
142        Ok(s) if s.trim().is_empty() => false,
143        Ok(s) if falsy(&s) => false,
144        Ok(s) => truthy(&s),
145    }
146}
147
148fn backend_from_env() -> String {
149    let from_var = std::env::var(ENV_DEVSHELL_VM_BACKEND)
150        .ok()
151        .map(|s| s.trim().to_string())
152        .filter(|s| !s.is_empty());
153    if let Some(b) = from_var {
154        return b;
155    }
156    if cfg!(test) {
157        "auto".to_string()
158    } else {
159        default_backend_for_release()
160    }
161}
162
163impl VmConfig {
164    /// Read configuration from process environment.
165    #[must_use]
166    pub fn from_env() -> Self {
167        let enabled = vm_enabled_from_env();
168
169        let backend = backend_from_env();
170
171        let eager_start = std::env::var(ENV_DEVSHELL_VM_EAGER)
172            .map(|s| truthy(&s))
173            .unwrap_or(false);
174
175        let lima_instance = std::env::var(ENV_DEVSHELL_VM_LIMA_INSTANCE)
176            .ok()
177            .map(|s| s.trim().to_string())
178            .filter(|s| !s.is_empty())
179            .unwrap_or_else(|| "devshell-rust".to_string());
180
181        Self {
182            enabled,
183            backend,
184            eager_start,
185            lima_instance,
186        }
187    }
188
189    /// Config with VM mode off (for tests).
190    #[must_use]
191    pub fn disabled() -> Self {
192        Self {
193            enabled: false,
194            backend: String::new(),
195            eager_start: false,
196            lima_instance: String::new(),
197        }
198    }
199
200    /// Normalized backend: `host` and `auto` use the host temp sandbox; `lima` uses γ (Unix; see `docs/devshell-vm-gamma.md`).
201    #[must_use]
202    pub fn use_host_sandbox(&self) -> bool {
203        let b = self.backend.to_ascii_lowercase();
204        b == "host" || b == "auto" || b.is_empty()
205    }
206
207    /// Effective workspace mode after combining [`workspace_mode_from_env`] with VM availability (guest-primary design §6).
208    ///
209    /// Returns [`WorkspaceMode::Guest`] only when the user requested **`guest`**, [`VmConfig::enabled`] is true,
210    /// [`VmConfig::use_host_sandbox`] is false, and the backend is **`lima`** or **`beta`**. Otherwise returns
211    /// [`WorkspaceMode::Sync`] without erroring.
212    #[must_use]
213    pub fn workspace_mode_effective(&self) -> WorkspaceMode {
214        let requested = workspace_mode_from_env();
215        if matches!(requested, WorkspaceMode::Sync) {
216            return WorkspaceMode::Sync;
217        }
218
219        let effective = if !self.enabled || self.use_host_sandbox() {
220            WorkspaceMode::Sync
221        } else {
222            let b = self.backend.to_ascii_lowercase();
223            if b == "lima" || b == "beta" {
224                WorkspaceMode::Guest
225            } else {
226                WorkspaceMode::Sync
227            }
228        };
229
230        if matches!(requested, WorkspaceMode::Guest)
231            && matches!(effective, WorkspaceMode::Sync)
232            && !cfg!(test)
233        {
234            eprintln!(
235                "dev_shell: DEVSHELL_VM_WORKSPACE_MODE=guest requires VM enabled and backend lima or beta; using sync mode."
236            );
237        }
238
239        effective
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::sync::{Mutex, OnceLock};
246
247    use super::*;
248
249    fn vm_env_lock() -> std::sync::MutexGuard<'static, ()> {
250        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
251        LOCK.get_or_init(|| Mutex::new(()))
252            .lock()
253            .unwrap_or_else(|e| e.into_inner())
254    }
255
256    fn set_env(key: &str, val: Option<&str>) {
257        match val {
258            Some(v) => std::env::set_var(key, v),
259            None => std::env::remove_var(key),
260        }
261    }
262
263    #[test]
264    fn from_env_devshell_vm_on() {
265        let _g = vm_env_lock();
266        let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
267        let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
268        set_env(ENV_DEVSHELL_VM, Some("on"));
269        set_env(ENV_DEVSHELL_VM_BACKEND, None);
270        let c = VmConfig::from_env();
271        assert!(c.enabled);
272        assert_eq!(c.backend, "auto");
273        set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
274        set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
275    }
276
277    #[test]
278    fn from_env_defaults_off() {
279        let _g = vm_env_lock();
280        let old = std::env::var(ENV_DEVSHELL_VM).ok();
281        set_env(ENV_DEVSHELL_VM, None);
282        let c = VmConfig::from_env();
283        assert!(!c.enabled);
284        set_env(ENV_DEVSHELL_VM, old.as_deref());
285    }
286
287    #[test]
288    fn from_env_explicit_off_disables_vm() {
289        let _g = vm_env_lock();
290        let old = std::env::var(ENV_DEVSHELL_VM).ok();
291        set_env(ENV_DEVSHELL_VM, Some("off"));
292        let c = VmConfig::from_env();
293        assert!(!c.enabled);
294        set_env(ENV_DEVSHELL_VM, old.as_deref());
295    }
296
297    #[test]
298    fn use_host_sandbox_lima_false() {
299        let mut c = VmConfig::disabled();
300        c.backend = "lima".to_string();
301        assert!(!c.use_host_sandbox());
302    }
303
304    #[test]
305    fn workspace_mode_from_env_unset_defaults_sync() {
306        let _g = vm_env_lock();
307        let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
308        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, None);
309        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Sync);
310        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
311    }
312
313    #[test]
314    fn workspace_mode_from_env_guest() {
315        let _g = vm_env_lock();
316        let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
317        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
318        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
319        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("GUEST"));
320        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
321        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
322    }
323
324    #[test]
325    fn workspace_mode_effective_guest_plus_host_sandbox_forces_sync() {
326        let _g = vm_env_lock();
327        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
328        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
329        let mut c = VmConfig::disabled();
330        c.enabled = true;
331        c.backend = "host".to_string();
332        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
333        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
334    }
335
336    #[test]
337    fn workspace_mode_effective_guest_vm_off_forces_sync() {
338        let _g = vm_env_lock();
339        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
340        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
341        let mut c = VmConfig::disabled();
342        c.enabled = false;
343        c.backend = "lima".to_string();
344        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
345        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
346    }
347
348    #[test]
349    fn workspace_mode_effective_guest_lima_enabled() {
350        let _g = vm_env_lock();
351        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
352        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
353        let mut c = VmConfig::disabled();
354        c.enabled = true;
355        c.backend = "lima".to_string();
356        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Guest);
357        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
358    }
359
360    #[test]
361    fn workspace_mode_effective_sync_env_ignores_backend() {
362        let _g = vm_env_lock();
363        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
364        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("sync"));
365        let mut c = VmConfig::disabled();
366        c.enabled = true;
367        c.backend = "lima".to_string();
368        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
369        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
370    }
371
372    #[cfg(unix)]
373    #[test]
374    fn try_from_config_lima_depends_on_limactl_in_path() {
375        use super::super::{SessionHolder, VmError};
376        use crate::devshell::sandbox;
377
378        let _g = vm_env_lock();
379        let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
380        let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
381        set_env(ENV_DEVSHELL_VM, Some("1"));
382        set_env(ENV_DEVSHELL_VM_BACKEND, Some("lima"));
383        let c = VmConfig::from_env();
384        assert!(c.enabled);
385        assert!(!c.use_host_sandbox());
386        let r = SessionHolder::try_from_config(&c);
387        match sandbox::find_in_path("limactl") {
388            Some(_) => assert!(
389                matches!(r, Ok(SessionHolder::Gamma(_))),
390                "expected Gamma session when limactl is in PATH, got {r:?}"
391            ),
392            None => assert!(
393                matches!(r, Err(VmError::Lima(_))),
394                "expected Lima error when limactl missing, got {r:?}"
395            ),
396        }
397        set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
398        set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
399    }
400
401    #[cfg(not(unix))]
402    #[test]
403    fn try_from_config_lima_errors_on_non_unix() {
404        use super::super::{SessionHolder, VmError};
405
406        let mut c = VmConfig::disabled();
407        c.enabled = true;
408        c.backend = "lima".to_string();
409        c.lima_instance = "devshell-rust".to_string();
410        let r = SessionHolder::try_from_config(&c);
411        assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
412    }
413
414    #[cfg(all(unix, not(feature = "beta-vm")))]
415    #[test]
416    fn try_from_config_beta_requires_feature_flag() {
417        use super::super::{SessionHolder, VmError};
418
419        let mut c = VmConfig::disabled();
420        c.enabled = true;
421        c.backend = "beta".to_string();
422        c.lima_instance = "devshell-rust".to_string();
423        let r = SessionHolder::try_from_config(&c);
424        let Err(VmError::BackendNotImplemented(msg)) = r else {
425            panic!("expected BackendNotImplemented, got {r:?}");
426        };
427        assert!(
428            msg.contains("beta-vm"),
429            "message should mention beta-vm: {msg}"
430        );
431    }
432
433    /// Without `beta-vm` (e.g. `cargo test -p xtask-todo-lib --no-default-features`), `beta` backend is unavailable.
434    #[cfg(all(not(unix), not(feature = "beta-vm")))]
435    #[test]
436    fn try_from_config_beta_errors_without_beta_vm_feature() {
437        use super::super::{SessionHolder, VmError};
438
439        let mut c = VmConfig::disabled();
440        c.enabled = true;
441        c.backend = "beta".to_string();
442        c.lima_instance = "devshell-rust".to_string();
443        let r = SessionHolder::try_from_config(&c);
444        assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
445    }
446}