xtask_todo_lib/devshell/vm/
mod.rs1#![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#[derive(Debug)]
56pub enum VmError {
57 Sandbox(sandbox::SandboxError),
58 Sync(VmSyncError),
59 BackendNotImplemented(&'static str),
61 Lima(String),
63 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
89pub trait VmExecutionSession {
91 fn ensure_ready(&mut self, vfs: &Vfs, vfs_cwd: &str) -> Result<(), VmError>;
93
94 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 fn shutdown(&mut self, vfs: &mut Vfs, vfs_cwd: &str) -> Result<(), VmError>;
105}
106
107#[derive(Debug)]
109pub enum SessionHolder {
110 Host(HostSandboxSession),
111 #[cfg(unix)]
113 Gamma(GammaSession),
114 #[cfg(feature = "beta-vm")]
116 Beta(session_beta::BetaSession),
117}
118
119#[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 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
290 pub fn is_host_only(&self) -> bool {
291 matches!(self, Self::Host(_))
292 }
293
294 #[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#[allow(clippy::result_unit_err)] pub 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
335pub 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#[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#[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}