xtask_todo_lib/devshell/vm/
mod.rs1use 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#[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) | 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#[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
100pub trait VmExecutionSession {
102 fn ensure_ready(&mut self, _vfs: &Vfs, _vfs_cwd: &str) -> Result<(), VmError> {
107 Ok(())
108 }
109
110 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 fn shutdown(&mut self, _vfs: &mut Vfs, _vfs_cwd: &str) -> Result<(), VmError> {
127 Ok(())
128 }
129}
130
131#[derive(Debug)]
133pub enum SessionHolder {
134 Host(HostSandboxSession),
135 #[cfg(unix)]
137 Gamma(GammaSession),
138 #[cfg(feature = "beta-vm")]
140 Beta(session_beta::BetaSession),
141}
142
143#[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 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 #[must_use]
211 pub const fn new_host() -> Self {
212 Self::Host(HostSandboxSession::new())
213 }
214
215 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 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 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 #[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 #[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 #[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 #[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 #[must_use]
320 pub const fn is_host_only(&self) -> bool {
321 matches!(self, Self::Host(_))
322 }
323
324 #[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
352pub 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
371pub 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#[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#[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;