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, **`stdio`** (default) maps the host workspace to **`/mnt/<drive>/…`** inside Podman Machine for
35/// `session_start` **`staging_dir`** unless you set this explicitly.
36pub const ENV_DEVSHELL_VM_BETA_SESSION_STAGING: &str = "DEVSHELL_VM_BETA_SESSION_STAGING";
37
38/// When set (any value), skip **`podman machine ssh`** bootstrap on Windows: no Podman check / no requirement
39/// that the Linux `devshell-vm` binary exists (tests or fully manual β setup).
40pub const ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP: &str = "DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP";
41
42/// **Windows β:** optional full **Windows** path to the **Linux** `devshell-vm` binary
43/// (`x86_64-unknown-linux-gnu` / ELF) for **`podman machine ssh`** transport.
44///
45/// If unset, the binary is searched under **`$repo_root/target/x86_64-unknown-linux-gnu/release/devshell-vm`**
46/// where `repo_root` is discovered from cwd, [`ENV_DEVSHELL_VM_REPO_ROOT`], or walking up from the workspace
47/// parent — **not** the ephemeral `cargo-devshell-exports` tree. If still not found, **automatic fallback
48/// uses [`ENV_DEVSHELL_VM_CONTAINER_IMAGE`] with `podman run -i`** (see `podman_machine.rs`).
49pub const ENV_DEVSHELL_VM_LINUX_BINARY: &str = "DEVSHELL_VM_LINUX_BINARY";
50
51/// **Windows β:** optional **Windows** path to an **xtask_todo** repository root (directory containing
52/// **`containers/devshell-vm/Containerfile`**). Locates **`target/x86_64-unknown-linux-gnu/release/devshell-vm`**
53/// when [`ENV_DEVSHELL_VM_LINUX_BINARY`] is unset. Useful if you keep a checkout for building the sidecar but run
54/// **`cargo devshell`** from other directories; **not** applicable when you only have a crates.io install and no clone.
55pub const ENV_DEVSHELL_VM_REPO_ROOT: &str = "DEVSHELL_VM_REPO_ROOT";
56
57/// **Windows β:** OCI image used when **no** host Linux `devshell-vm` ELF is found: `podman run -i` with
58/// **`--serve-stdio`** and the workspace mounted at **`/workspace`** (no host TCP).
59/// Default: **`ghcr.io/tangcan/xtask_todo/devshell-vm:v{CARGO_PKG_VERSION}`** (published by CI on release).
60pub const ENV_DEVSHELL_VM_CONTAINER_IMAGE: &str = "DEVSHELL_VM_CONTAINER_IMAGE";
61
62/// **Windows β:** stdio transport for `DEVSHELL_VM_SOCKET=stdio`: **`auto`** (default), **`machine-ssh`**
63/// (host ELF + `podman machine ssh`), or **`podman-run`** (OCI image + `podman run -i`).
64pub const ENV_DEVSHELL_VM_STDIO_TRANSPORT: &str = "DEVSHELL_VM_STDIO_TRANSPORT";
65
66/// When set (any value), do **not** isolate **`USERPROFILE` / `HOME`** for `podman` subprocesses (Windows).
67/// By default we point **`USERPROFILE`** (Go’s `UserHomeDir()` on Windows — not only `HOME`) at a writable
68/// temp “profile” with an **empty default** `.ssh/known_hosts`, so a **locked, protected, or invalid**
69/// **`%USERPROFILE%\.ssh\known_hosts`** is not read. An existing Podman Machine dir is **symlinked** in when
70/// possible (see `podman_machine.rs`).
71pub const ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME: &str = "DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME";
72
73/// `DEVSHELL_VM_WORKSPACE_MODE` — **`sync`** (default) or **`guest`** (Mode P; guest filesystem as source of truth).
74///
75/// **`guest`** is effective only when the VM is enabled and the backend is **`lima`** or **`beta`**; otherwise
76/// [`VmConfig::workspace_mode_effective`] returns [`WorkspaceMode::Sync`] (design `2026-03-20-devshell-guest-primary-design.md` §6).
77///
78/// **Unset** (including **`cfg(test)`**): [`WorkspaceMode::Sync`].
79pub const ENV_DEVSHELL_VM_WORKSPACE_MODE: &str = "DEVSHELL_VM_WORKSPACE_MODE";
80
81/// How the devshell workspace is backed: memory VFS + push/pull (**[`WorkspaceMode::Sync`]**) vs guest-primary (**[`WorkspaceMode::Guest`]**, planned).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum WorkspaceMode {
84    /// Mode S: in-memory `Vfs` authority; `cargo`/`rustup` sync with guest when using γ.
85    Sync,
86    /// Mode P: guest mount is the source of truth for the project tree (incremental implementation).
87    Guest,
88}
89
90/// Read [`ENV_DEVSHELL_VM_WORKSPACE_MODE`] from the environment.
91#[must_use]
92pub fn workspace_mode_from_env() -> WorkspaceMode {
93    match std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE) {
94        Ok(s) if s.trim().eq_ignore_ascii_case("guest") => WorkspaceMode::Guest,
95        _ => WorkspaceMode::Sync,
96    }
97}
98
99/// Parsed VM-related environment.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VmConfig {
102    /// `DEVSHELL_VM` enabled.
103    pub enabled: bool,
104    /// Raw backend string (trimmed); see `ENV_DEVSHELL_VM_BACKEND` for defaults.
105    pub backend: String,
106    /// Eager VM/session start when REPL opens (vs lazy on first `rustup`/`cargo`).
107    pub eager_start: bool,
108    /// Lima instance name.
109    pub lima_instance: String,
110}
111
112fn truthy(s: &str) -> bool {
113    let s = s.trim();
114    s == "1"
115        || s.eq_ignore_ascii_case("true")
116        || s.eq_ignore_ascii_case("yes")
117        || s.eq_ignore_ascii_case("on")
118}
119
120fn falsy(s: &str) -> bool {
121    let s = s.trim();
122    s == "0"
123        || s.eq_ignore_ascii_case("false")
124        || s.eq_ignore_ascii_case("no")
125        || s.eq_ignore_ascii_case("off")
126}
127
128fn devshell_repo_root_walk(mut dir: std::path::PathBuf) -> Option<std::path::PathBuf> {
129    loop {
130        let cf = dir.join("containers/devshell-vm/Containerfile");
131        if cf.is_file() {
132            return Some(dir);
133        }
134        if !dir.pop() {
135            break;
136        }
137    }
138    None
139}
140
141/// Walk parents from [`std::env::current_dir`] looking for `containers/devshell-vm/Containerfile` (xtask_todo repo).
142#[cfg_attr(not(windows), allow(dead_code))]
143#[cfg(feature = "beta-vm")]
144pub(crate) fn devshell_repo_root_with_containerfile() -> Option<std::path::PathBuf> {
145    let dir = std::env::current_dir().ok()?;
146    devshell_repo_root_walk(dir)
147}
148
149/// Same as [`devshell_repo_root_with_containerfile`] but starting from `start` (e.g. workspace parent).
150#[cfg_attr(not(windows), allow(dead_code))]
151#[cfg(feature = "beta-vm")]
152pub(crate) fn devshell_repo_root_from_path(start: &std::path::Path) -> Option<std::path::PathBuf> {
153    devshell_repo_root_walk(start.to_path_buf())
154}
155
156fn default_backend_for_release() -> String {
157    #[cfg(all(windows, feature = "beta-vm"))]
158    {
159        return "beta".to_string();
160    }
161    #[cfg(unix)]
162    {
163        "lima".to_string()
164    }
165    #[cfg(not(any(unix, all(windows, feature = "beta-vm"))))]
166    {
167        "host".to_string()
168    }
169}
170
171fn vm_enabled_from_env() -> bool {
172    if cfg!(test) {
173        return std::env::var(ENV_DEVSHELL_VM)
174            .map(|s| truthy(&s))
175            .unwrap_or(false);
176    }
177    match std::env::var(ENV_DEVSHELL_VM) {
178        Err(_) => true,
179        Ok(s) if s.trim().is_empty() => false,
180        Ok(s) if falsy(&s) => false,
181        Ok(s) => truthy(&s),
182    }
183}
184
185fn backend_from_env() -> String {
186    let from_var = std::env::var(ENV_DEVSHELL_VM_BACKEND)
187        .ok()
188        .map(|s| s.trim().to_string())
189        .filter(|s| !s.is_empty());
190    if let Some(b) = from_var {
191        return b;
192    }
193    if cfg!(test) {
194        "auto".to_string()
195    } else {
196        default_backend_for_release()
197    }
198}
199
200impl VmConfig {
201    /// Read configuration from process environment.
202    #[must_use]
203    pub fn from_env() -> Self {
204        let enabled = vm_enabled_from_env();
205
206        let backend = backend_from_env();
207
208        let eager_start = std::env::var(ENV_DEVSHELL_VM_EAGER)
209            .map(|s| truthy(&s))
210            .unwrap_or(false);
211
212        let lima_instance = std::env::var(ENV_DEVSHELL_VM_LIMA_INSTANCE)
213            .ok()
214            .map(|s| s.trim().to_string())
215            .filter(|s| !s.is_empty())
216            .unwrap_or_else(|| "devshell-rust".to_string());
217
218        Self {
219            enabled,
220            backend,
221            eager_start,
222            lima_instance,
223        }
224    }
225
226    /// Config with VM mode off (for tests).
227    #[must_use]
228    pub fn disabled() -> Self {
229        Self {
230            enabled: false,
231            backend: String::new(),
232            eager_start: false,
233            lima_instance: String::new(),
234        }
235    }
236
237    /// Normalized backend: `host` and `auto` use the host temp sandbox; `lima` uses γ (Unix; see `docs/devshell-vm-gamma.md`).
238    #[must_use]
239    pub fn use_host_sandbox(&self) -> bool {
240        let b = self.backend.to_ascii_lowercase();
241        b == "host" || b == "auto" || b.is_empty()
242    }
243
244    /// Effective workspace mode after combining [`workspace_mode_from_env`] with VM availability (guest-primary design §6).
245    ///
246    /// Returns [`WorkspaceMode::Guest`] only when the user requested **`guest`**, [`VmConfig::enabled`] is true,
247    /// [`VmConfig::use_host_sandbox`] is false, and the backend is **`lima`** or **`beta`**. Otherwise returns
248    /// [`WorkspaceMode::Sync`] without erroring.
249    #[must_use]
250    pub fn workspace_mode_effective(&self) -> WorkspaceMode {
251        let requested = workspace_mode_from_env();
252        if matches!(requested, WorkspaceMode::Sync) {
253            return WorkspaceMode::Sync;
254        }
255
256        let effective = if !self.enabled || self.use_host_sandbox() {
257            WorkspaceMode::Sync
258        } else {
259            let b = self.backend.to_ascii_lowercase();
260            if b == "lima" || b == "beta" {
261                WorkspaceMode::Guest
262            } else {
263                WorkspaceMode::Sync
264            }
265        };
266
267        if matches!(requested, WorkspaceMode::Guest)
268            && matches!(effective, WorkspaceMode::Sync)
269            && !cfg!(test)
270        {
271            eprintln!(
272                "dev_shell: DEVSHELL_VM_WORKSPACE_MODE=guest requires VM enabled and backend lima or beta; using sync mode."
273            );
274        }
275
276        effective
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use std::sync::{Mutex, OnceLock};
283
284    use super::*;
285
286    fn vm_env_lock() -> std::sync::MutexGuard<'static, ()> {
287        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
288        LOCK.get_or_init(|| Mutex::new(()))
289            .lock()
290            .unwrap_or_else(|e| e.into_inner())
291    }
292
293    fn set_env(key: &str, val: Option<&str>) {
294        match val {
295            Some(v) => std::env::set_var(key, v),
296            None => std::env::remove_var(key),
297        }
298    }
299
300    #[test]
301    fn from_env_devshell_vm_on() {
302        let _g = vm_env_lock();
303        let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
304        let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
305        set_env(ENV_DEVSHELL_VM, Some("on"));
306        set_env(ENV_DEVSHELL_VM_BACKEND, None);
307        let c = VmConfig::from_env();
308        assert!(c.enabled);
309        assert_eq!(c.backend, "auto");
310        set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
311        set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
312    }
313
314    #[test]
315    fn from_env_defaults_off() {
316        let _g = vm_env_lock();
317        let old = std::env::var(ENV_DEVSHELL_VM).ok();
318        set_env(ENV_DEVSHELL_VM, None);
319        let c = VmConfig::from_env();
320        assert!(!c.enabled);
321        set_env(ENV_DEVSHELL_VM, old.as_deref());
322    }
323
324    #[test]
325    fn from_env_explicit_off_disables_vm() {
326        let _g = vm_env_lock();
327        let old = std::env::var(ENV_DEVSHELL_VM).ok();
328        set_env(ENV_DEVSHELL_VM, Some("off"));
329        let c = VmConfig::from_env();
330        assert!(!c.enabled);
331        set_env(ENV_DEVSHELL_VM, old.as_deref());
332    }
333
334    #[test]
335    fn use_host_sandbox_lima_false() {
336        let mut c = VmConfig::disabled();
337        c.backend = "lima".to_string();
338        assert!(!c.use_host_sandbox());
339    }
340
341    #[test]
342    fn workspace_mode_from_env_unset_defaults_sync() {
343        let _g = vm_env_lock();
344        let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
345        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, None);
346        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Sync);
347        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
348    }
349
350    #[test]
351    fn workspace_mode_from_env_guest() {
352        let _g = vm_env_lock();
353        let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
354        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
355        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
356        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("GUEST"));
357        assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
358        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
359    }
360
361    #[test]
362    fn workspace_mode_effective_guest_plus_host_sandbox_forces_sync() {
363        let _g = vm_env_lock();
364        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
365        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
366        let mut c = VmConfig::disabled();
367        c.enabled = true;
368        c.backend = "host".to_string();
369        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
370        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
371    }
372
373    #[test]
374    fn workspace_mode_effective_guest_vm_off_forces_sync() {
375        let _g = vm_env_lock();
376        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
377        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
378        let mut c = VmConfig::disabled();
379        c.enabled = false;
380        c.backend = "lima".to_string();
381        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
382        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
383    }
384
385    #[test]
386    fn workspace_mode_effective_guest_lima_enabled() {
387        let _g = vm_env_lock();
388        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
389        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
390        let mut c = VmConfig::disabled();
391        c.enabled = true;
392        c.backend = "lima".to_string();
393        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Guest);
394        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
395    }
396
397    #[test]
398    fn workspace_mode_effective_sync_env_ignores_backend() {
399        let _g = vm_env_lock();
400        let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
401        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("sync"));
402        let mut c = VmConfig::disabled();
403        c.enabled = true;
404        c.backend = "lima".to_string();
405        assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
406        set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
407    }
408
409    #[cfg(unix)]
410    #[test]
411    fn try_from_config_lima_depends_on_limactl_in_path() {
412        use super::super::{SessionHolder, VmError};
413        use crate::devshell::sandbox;
414
415        let _g = vm_env_lock();
416        let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
417        let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
418        set_env(ENV_DEVSHELL_VM, Some("1"));
419        set_env(ENV_DEVSHELL_VM_BACKEND, Some("lima"));
420        let c = VmConfig::from_env();
421        assert!(c.enabled);
422        assert!(!c.use_host_sandbox());
423        let r = SessionHolder::try_from_config(&c);
424        match sandbox::find_in_path("limactl") {
425            Some(_) => assert!(
426                matches!(r, Ok(SessionHolder::Gamma(_))),
427                "expected Gamma session when limactl is in PATH, got {r:?}"
428            ),
429            None => assert!(
430                matches!(r, Err(VmError::Lima(_))),
431                "expected Lima error when limactl missing, got {r:?}"
432            ),
433        }
434        set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
435        set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
436    }
437
438    #[cfg(not(unix))]
439    #[test]
440    fn try_from_config_lima_errors_on_non_unix() {
441        use super::super::{SessionHolder, VmError};
442
443        let mut c = VmConfig::disabled();
444        c.enabled = true;
445        c.backend = "lima".to_string();
446        c.lima_instance = "devshell-rust".to_string();
447        let r = SessionHolder::try_from_config(&c);
448        assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
449    }
450
451    #[cfg(all(unix, not(feature = "beta-vm")))]
452    #[test]
453    fn try_from_config_beta_requires_feature_flag() {
454        use super::super::{SessionHolder, VmError};
455
456        let mut c = VmConfig::disabled();
457        c.enabled = true;
458        c.backend = "beta".to_string();
459        c.lima_instance = "devshell-rust".to_string();
460        let r = SessionHolder::try_from_config(&c);
461        let Err(VmError::BackendNotImplemented(msg)) = r else {
462            panic!("expected BackendNotImplemented, got {r:?}");
463        };
464        assert!(
465            msg.contains("beta-vm"),
466            "message should mention beta-vm: {msg}"
467        );
468    }
469
470    /// Without `beta-vm` (e.g. `cargo test -p xtask-todo-lib --no-default-features`), `beta` backend is unavailable.
471    #[cfg(all(not(unix), not(feature = "beta-vm")))]
472    #[test]
473    fn try_from_config_beta_errors_without_beta_vm_feature() {
474        use super::super::{SessionHolder, VmError};
475
476        let mut c = VmConfig::disabled();
477        c.enabled = true;
478        c.backend = "beta".to_string();
479        c.lima_instance = "devshell-rust".to_string();
480        let r = SessionHolder::try_from_config(&c);
481        assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
482    }
483}