Skip to main content

xtask_todo_lib/devshell/vm/
mod.rs

1//! Optional session-scoped VM execution (γ CLI / β sidecar): host [`SessionHolder::Host`], Unix γ [`SessionHolder::Gamma`].
2
3use std::cell::RefCell;
4use std::io::Write;
5use std::rc::Rc;
6
7mod config;
8mod guest_fs_ops;
9#[cfg(unix)]
10mod lima_diagnostics;
11#[cfg(feature = "beta-vm")]
12mod podman_machine;
13#[cfg(feature = "beta-vm")]
14mod session_beta;
15#[cfg(unix)]
16mod session_gamma;
17mod session_host;
18pub mod sync;
19mod workspace_host;
20
21pub use config::{
22    exec_timeout_ms_from_env, workspace_mode_from_env, VmConfig, WorkspaceMode, ENV_DEVSHELL_VM,
23    ENV_DEVSHELL_VM_BACKEND, ENV_DEVSHELL_VM_BETA_SESSION_STAGING, ENV_DEVSHELL_VM_CONTAINER_IMAGE,
24    ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME, ENV_DEVSHELL_VM_EAGER,
25    ENV_DEVSHELL_VM_EXEC_TIMEOUT_MS, ENV_DEVSHELL_VM_LIMA_INSTANCE, ENV_DEVSHELL_VM_LINUX_BINARY,
26    ENV_DEVSHELL_VM_REPO_ROOT, ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP, ENV_DEVSHELL_VM_SOCKET,
27    ENV_DEVSHELL_VM_STDIO_TRANSPORT, ENV_DEVSHELL_VM_WORKSPACE_MODE,
28};
29#[cfg(unix)]
30pub use guest_fs_ops::LimaGuestFsOps;
31pub use guest_fs_ops::{
32    guest_path_is_under_mount, guest_project_dir_on_guest, normalize_guest_path, GuestFsError,
33    GuestFsOps, MockGuestFsOps,
34};
35#[cfg(unix)]
36pub use lima_diagnostics::ENV_DEVSHELL_VM_LIMA_HINTS;
37#[cfg(unix)]
38pub use session_gamma::{
39    GammaSession, ENV_DEVSHELL_VM_AUTO_BUILD_ESSENTIAL, ENV_DEVSHELL_VM_AUTO_BUILD_TODO_GUEST,
40    ENV_DEVSHELL_VM_AUTO_TODO_PATH, ENV_DEVSHELL_VM_GUEST_HOST_DIR,
41    ENV_DEVSHELL_VM_GUEST_TODO_HINT, ENV_DEVSHELL_VM_GUEST_WORKSPACE, ENV_DEVSHELL_VM_LIMACTL,
42    ENV_DEVSHELL_VM_STOP_ON_EXIT, ENV_DEVSHELL_VM_WORKSPACE_PARENT,
43    ENV_DEVSHELL_VM_WORKSPACE_USE_CARGO_ROOT,
44};
45pub use session_host::HostSandboxSession;
46pub use sync::{pull_workspace_to_vfs, push_full, push_incremental, VmSyncError};
47pub use workspace_host::workspace_parent_for_instance;
48
49use std::process::ExitStatus;
50
51use super::sandbox;
52use super::vfs::Vfs;
53
54/// Errors from VM session operations.
55#[derive(Debug)]
56pub enum VmError {
57    Sandbox(sandbox::SandboxError),
58    Sync(VmSyncError),
59    /// Backend not implemented on this OS or not wired yet.
60    BackendNotImplemented(&'static str),
61    /// Lima / `limactl` or γ orchestration failure (message for stderr).
62    Lima(String),
63    /// β IPC / `devshell-vm` protocol failure.
64    Ipc(String),
65}
66
67impl std::fmt::Display for VmError {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::Sandbox(e) => write!(f, "{e}"),
71            Self::Sync(e) => write!(f, "{e}"),
72            Self::BackendNotImplemented(s) => write!(f, "vm backend not implemented: {s}"),
73            Self::Lima(s) | Self::Ipc(s) => f.write_str(s),
74        }
75    }
76}
77
78impl std::error::Error for VmError {
79    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
80        match self {
81            Self::Sandbox(e) => Some(e),
82            Self::Sync(e) => Some(e),
83            Self::BackendNotImplemented(_) | Self::Lima(_) | Self::Ipc(_) => None,
84        }
85    }
86}
87
88/// Session-construction error already reported to stderr by [`try_session_rc`].
89#[derive(Debug, Clone, Copy)]
90pub struct VmSessionInitError;
91
92impl std::fmt::Display for VmSessionInitError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str("vm session init failed")
95    }
96}
97
98impl std::error::Error for VmSessionInitError {}
99
100/// Abstraction for a devshell execution session (host temp dir, γ VM, or β sidecar).
101pub trait VmExecutionSession {
102    /// Prepare the session (e.g. start VM, initial push). No-op for host temp export.
103    ///
104    /// # Errors
105    /// Returns backend-specific failures while preparing VM/sandbox state.
106    fn ensure_ready(&mut self, _vfs: &Vfs, _vfs_cwd: &str) -> Result<(), VmError> {
107        Ok(())
108    }
109
110    /// Run `rustup` or `cargo` with cwd matching `vfs_cwd`; update `vfs` as defined by the backend.
111    ///
112    /// # Errors
113    /// Returns backend execution or sync failures from the selected session implementation.
114    fn run_rust_tool(
115        &mut self,
116        vfs: &mut Vfs,
117        vfs_cwd: &str,
118        program: &str,
119        args: &[String],
120    ) -> Result<ExitStatus, VmError>;
121
122    /// Tear down (e.g. final pull, stop VM).
123    ///
124    /// # Errors
125    /// Returns backend-specific failures during shutdown/sync.
126    fn shutdown(&mut self, _vfs: &mut Vfs, _vfs_cwd: &str) -> Result<(), VmError> {
127        Ok(())
128    }
129}
130
131/// Active VM / sandbox backend for one REPL or script run.
132#[derive(Debug)]
133pub enum SessionHolder {
134    Host(HostSandboxSession),
135    /// γ: Lima + host workspace sync (Unix only).
136    #[cfg(unix)]
137    Gamma(GammaSession),
138    /// β: JSON-lines IPC to `devshell-vm` (Unix socket or TCP; `beta-vm` feature).
139    #[cfg(feature = "beta-vm")]
140    Beta(session_beta::BetaSession),
141}
142
143/// Single-quoted POSIX shell word (safe for `export PATH=…`).
144#[cfg(unix)]
145pub(crate) fn bash_single_quoted(s: &str) -> String {
146    let mut o = String::from("'");
147    for c in s.chars() {
148        if c == '\'' {
149            o.push_str("'\"'\"'");
150        } else {
151            o.push(c);
152        }
153    }
154    o.push('\'');
155    o
156}
157
158#[cfg(all(unix, test))]
159mod bash_single_quoted_tests {
160    use super::bash_single_quoted;
161
162    #[test]
163    fn wraps_plain_path() {
164        assert_eq!(
165            bash_single_quoted("/workspace/p/target/release"),
166            "'/workspace/p/target/release'"
167        );
168    }
169}
170
171impl SessionHolder {
172    /// Build session from config.
173    ///
174    /// # Errors
175    /// On Unix, `DEVSHELL_VM_BACKEND=lima` uses [`GammaSession`]; fails with [`VmError::Lima`] if `limactl` is missing.
176    /// On non-Unix, `lima` returns [`VmError::BackendNotImplemented`].
177    pub fn try_from_config(config: &VmConfig) -> Result<Self, VmError> {
178        if !config.enabled {
179            return Ok(Self::Host(HostSandboxSession::new()));
180        }
181        if config.use_host_sandbox() {
182            return Ok(Self::Host(HostSandboxSession::new()));
183        }
184        #[cfg(feature = "beta-vm")]
185        if config.backend.eq_ignore_ascii_case("beta") {
186            return session_beta::BetaSession::new(config).map(SessionHolder::Beta);
187        }
188        #[cfg(not(feature = "beta-vm"))]
189        if config.backend.eq_ignore_ascii_case("beta") {
190            return Err(VmError::BackendNotImplemented(
191                "DEVSHELL_VM_BACKEND=beta requires building xtask-todo-lib with `--features beta-vm`",
192            ));
193        }
194        #[cfg(unix)]
195        if config.backend.eq_ignore_ascii_case("lima") {
196            return GammaSession::new(config).map(SessionHolder::Gamma);
197        }
198        #[cfg(not(unix))]
199        if config.backend.eq_ignore_ascii_case("lima") {
200            return Err(VmError::BackendNotImplemented(
201                "lima backend is only supported on Linux and macOS",
202            ));
203        }
204        Err(VmError::BackendNotImplemented(
205            "unknown DEVSHELL_VM_BACKEND (try host, auto, lima, or beta); see docs/devshell-vm-gamma.md",
206        ))
207    }
208
209    /// Host sandbox only (tests and callers that do not read `VmConfig`).
210    #[must_use]
211    pub const fn new_host() -> Self {
212        Self::Host(HostSandboxSession::new())
213    }
214
215    /// # Errors
216    /// Returns backend-specific failures while preparing VM/sandbox state.
217    pub fn ensure_ready(&mut self, vfs: &Vfs, vfs_cwd: &str) -> Result<(), VmError> {
218        match self {
219            Self::Host(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
220            #[cfg(unix)]
221            Self::Gamma(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
222            #[cfg(feature = "beta-vm")]
223            Self::Beta(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
224        }
225    }
226
227    /// # Errors
228    /// Returns backend execution or sync failures from the selected session implementation.
229    pub fn run_rust_tool(
230        &mut self,
231        vfs: &mut Vfs,
232        vfs_cwd: &str,
233        program: &str,
234        args: &[String],
235    ) -> Result<ExitStatus, VmError> {
236        match self {
237            Self::Host(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
238            #[cfg(unix)]
239            Self::Gamma(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
240            #[cfg(feature = "beta-vm")]
241            Self::Beta(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
242        }
243    }
244
245    /// # Errors
246    /// Returns backend-specific failures during shutdown/sync.
247    pub fn shutdown(&mut self, vfs: &mut Vfs, vfs_cwd: &str) -> Result<(), VmError> {
248        match self {
249            Self::Host(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
250            #[cfg(unix)]
251            Self::Gamma(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
252            #[cfg(feature = "beta-vm")]
253            Self::Beta(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
254        }
255    }
256
257    /// When γ is in guest-primary mode ([`WorkspaceMode::Guest`]), returns the session for direct guest FS ops.
258    ///
259    /// Returns `None` for host sandbox, β, or Mode S γ (push/pull sync).
260    #[cfg(unix)]
261    #[must_use]
262    pub const fn guest_primary_gamma_mut(&mut self) -> Option<&mut GammaSession> {
263        match self {
264            Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => Some(g),
265            _ => None,
266        }
267    }
268
269    /// γ **or** β guest-primary: [`GuestFsOps`] + guest mount for [`crate::devshell::workspace::logical_path_to_guest`].
270    ///
271    /// Returns `None` for host sandbox, Mode S sync, or non–guest-primary sessions.
272    /// Mount is owned so the returned trait object does not alias a borrow of the session.
273    #[must_use]
274    pub fn guest_primary_fs_ops_mut(&mut self) -> Option<(&mut dyn GuestFsOps, String)> {
275        match self {
276            #[cfg(unix)]
277            Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => {
278                let mount = g.guest_mount().to_string();
279                Some((g as &mut dyn GuestFsOps, mount))
280            }
281            #[cfg(feature = "beta-vm")]
282            Self::Beta(b) if !b.syncs_vfs_with_host_workspace() => {
283                let mount = b.guest_mount().to_string();
284                Some((b as &mut dyn GuestFsOps, mount))
285            }
286            _ => None,
287        }
288    }
289
290    /// `true` when **any** VM session runs in guest-primary mode (γ or β: no VFS↔host project-tree sync).
291    #[must_use]
292    pub const fn is_guest_primary(&self) -> bool {
293        match self {
294            #[cfg(unix)]
295            Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => true,
296            #[cfg(feature = "beta-vm")]
297            Self::Beta(b) if !b.syncs_vfs_with_host_workspace() => true,
298            _ => false,
299        }
300    }
301
302    /// `true` when γ runs in guest-primary mode (no VFS↔host project-tree sync).
303    #[must_use]
304    pub const fn is_guest_primary_gamma(&self) -> bool {
305        #[cfg(unix)]
306        {
307            matches!(
308                self,
309                Self::Gamma(g) if !g.syncs_vfs_with_host_workspace()
310            )
311        }
312        #[cfg(not(unix))]
313        {
314            false
315        }
316    }
317
318    /// `true` when using the host temp sandbox ([`HostSandboxSession`]) rather than γ/β.
319    #[must_use]
320    pub const fn is_host_only(&self) -> bool {
321        matches!(self, Self::Host(_))
322    }
323
324    /// Replace this process with an interactive `limactl shell` (`bash -l`) under the guest workspace mount.
325    ///
326    /// On success, does not return. On failure, returns the [`std::io::Error`] from [`std::os::unix::process::CommandExt::exec`].
327    #[cfg(unix)]
328    #[must_use]
329    pub fn exec_lima_interactive_shell(&self) -> std::io::Error {
330        use std::os::unix::process::CommandExt;
331        use std::process::Command;
332        match self {
333            Self::Gamma(g) => {
334                let (workdir, inner) = g.lima_interactive_shell_workdir_and_inner();
335                Command::new(g.limactl_path())
336                    .arg("shell")
337                    .arg("-y")
338                    .arg("--workdir")
339                    .arg(workdir)
340                    .arg(g.lima_instance_name())
341                    .arg("--")
342                    .arg("bash")
343                    .arg("-lc")
344                    .arg(inner)
345                    .exec()
346            }
347            _ => std::io::Error::other("exec_lima_interactive_shell: not a Lima gamma session"),
348        }
349    }
350}
351
352/// Build [`SessionHolder`] from the environment.
353///
354/// On failure (e.g. default γ Lima but `limactl` missing), writes to `stderr` and returns an error.
355/// Use **`DEVSHELL_VM=off`** or **`DEVSHELL_VM_BACKEND=host`** to force the host temp sandbox.
356/// # Errors
357/// Returns [`VmSessionInitError`] when backend session construction fails.
358pub fn try_session_rc(
359    stderr: &mut dyn Write,
360) -> Result<Rc<RefCell<SessionHolder>>, VmSessionInitError> {
361    let config = VmConfig::from_env();
362    match SessionHolder::try_from_config(&config) {
363        Ok(s) => Ok(Rc::new(RefCell::new(s))),
364        Err(e) => {
365            let _ = writeln!(stderr, "dev_shell: {e}");
366            Err(VmSessionInitError)
367        }
368    }
369}
370
371/// Like [`try_session_rc`], but on failure uses [`SessionHolder::Host`] so the REPL can run against
372/// [`workspace_parent_for_instance`] (same tree as the Lima mount).
373pub fn try_session_rc_or_host(stderr: &mut dyn Write) -> Rc<RefCell<SessionHolder>> {
374    try_session_rc(stderr).unwrap_or_else(|_| {
375            let _ = writeln!(
376                stderr,
377                "dev_shell: VM unavailable — in-process REPL uses the same host directory as the Lima workspace (DEVSHELL_WORKSPACE_ROOT)."
378            );
379            Rc::new(RefCell::new(SessionHolder::Host(HostSandboxSession::new())))
380        })
381}
382
383#[cfg(unix)]
384pub fn export_devshell_workspace_root_env() {
385    #[cfg(test)]
386    let _workspace_env_test_guard = crate::test_support::devshell_workspace_env_mutex();
387    let c = config::VmConfig::from_env();
388    let p = session_gamma::workspace_parent_for_instance(&c.lima_instance);
389    let _ = std::fs::create_dir_all(&p);
390    if let Ok(can) = p.canonicalize() {
391        std::env::set_var("DEVSHELL_WORKSPACE_ROOT", can.as_os_str());
392    }
393}
394
395#[cfg(not(unix))]
396pub fn export_devshell_workspace_root_env() {}
397
398/// Host directory that Lima mounts at the guest workspace (e.g. `/workspace`).
399#[cfg(unix)]
400#[must_use]
401pub fn vm_workspace_host_root() -> std::path::PathBuf {
402    let c = config::VmConfig::from_env();
403    session_gamma::workspace_parent_for_instance(&c.lima_instance)
404}
405
406/// Stub for non-Unix targets: `devshell/mod.rs` uses `if cfg!(unix) && …` but the branch is still
407/// type-checked; this is never called when `cfg!(unix)` is false.
408#[cfg(not(unix))]
409#[must_use]
410pub fn vm_workspace_host_root() -> std::path::PathBuf {
411    std::path::PathBuf::new()
412}
413
414#[cfg(unix)]
415#[must_use]
416pub fn should_delegate_lima_shell(
417    vm_session: &Rc<RefCell<SessionHolder>>,
418    is_tty: bool,
419    run_script: bool,
420) -> bool {
421    if run_script || !is_tty {
422        return false;
423    }
424    if std::env::var("DEVSHELL_VM_INTERNAL_REPL").is_ok_and(|s| {
425        let s = s.trim();
426        s == "1" || s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("yes")
427    }) {
428        return false;
429    }
430    matches!(*vm_session.borrow(), SessionHolder::Gamma(_))
431}
432
433#[cfg(test)]
434mod tests;