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(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#[derive(Debug)]
57pub enum VmError {
58 Sandbox(sandbox::SandboxError),
59 Sync(VmSyncError),
60 BackendNotImplemented(&'static str),
62 Lima(String),
64 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
90pub trait VmExecutionSession {
92 fn ensure_ready(&mut self, vfs: &Vfs, vfs_cwd: &str) -> Result<(), VmError>;
94
95 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 fn shutdown(&mut self, vfs: &mut Vfs, vfs_cwd: &str) -> Result<(), VmError>;
106}
107
108#[derive(Debug)]
110pub enum SessionHolder {
111 Host(HostSandboxSession),
112 #[cfg(unix)]
114 Gamma(GammaSession),
115 #[cfg(feature = "beta-vm")]
117 Beta(session_beta::BetaSession),
118}
119
120#[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 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
291 pub fn is_host_only(&self) -> bool {
292 matches!(self, Self::Host(_))
293 }
294
295 #[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#[allow(clippy::result_unit_err)] pub 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
336pub 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#[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#[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}