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