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