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