xtask_todo_lib/devshell/vm/
config.rs1#![allow(clippy::pedantic, clippy::nursery)]
4
5pub const ENV_DEVSHELL_VM: &str = "DEVSHELL_VM";
11
12pub const ENV_DEVSHELL_VM_BACKEND: &str = "DEVSHELL_VM_BACKEND";
19
20pub const ENV_DEVSHELL_VM_EAGER: &str = "DEVSHELL_VM_EAGER";
22
23pub const ENV_DEVSHELL_VM_LIMA_INSTANCE: &str = "DEVSHELL_VM_LIMA_INSTANCE";
25
26pub const ENV_DEVSHELL_VM_SOCKET: &str = "DEVSHELL_VM_SOCKET";
28
29pub const ENV_DEVSHELL_VM_BETA_SESSION_STAGING: &str = "DEVSHELL_VM_BETA_SESSION_STAGING";
37
38pub const ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP: &str = "DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP";
41
42pub const ENV_DEVSHELL_VM_LINUX_BINARY: &str = "DEVSHELL_VM_LINUX_BINARY";
50
51pub const ENV_DEVSHELL_VM_REPO_ROOT: &str = "DEVSHELL_VM_REPO_ROOT";
56
57pub const ENV_DEVSHELL_VM_CONTAINER_IMAGE: &str = "DEVSHELL_VM_CONTAINER_IMAGE";
61
62pub const ENV_DEVSHELL_VM_STDIO_TRANSPORT: &str = "DEVSHELL_VM_STDIO_TRANSPORT";
65
66pub const ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME: &str = "DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME";
72
73pub const ENV_DEVSHELL_VM_WORKSPACE_MODE: &str = "DEVSHELL_VM_WORKSPACE_MODE";
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum WorkspaceMode {
84 Sync,
86 Guest,
88}
89
90#[must_use]
92pub fn workspace_mode_from_env() -> WorkspaceMode {
93 match std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE) {
94 Ok(s) if s.trim().eq_ignore_ascii_case("guest") => WorkspaceMode::Guest,
95 _ => WorkspaceMode::Sync,
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct VmConfig {
102 pub enabled: bool,
104 pub backend: String,
106 pub eager_start: bool,
108 pub lima_instance: String,
110}
111
112fn truthy(s: &str) -> bool {
113 let s = s.trim();
114 s == "1"
115 || s.eq_ignore_ascii_case("true")
116 || s.eq_ignore_ascii_case("yes")
117 || s.eq_ignore_ascii_case("on")
118}
119
120fn falsy(s: &str) -> bool {
121 let s = s.trim();
122 s == "0"
123 || s.eq_ignore_ascii_case("false")
124 || s.eq_ignore_ascii_case("no")
125 || s.eq_ignore_ascii_case("off")
126}
127
128fn devshell_repo_root_walk(mut dir: std::path::PathBuf) -> Option<std::path::PathBuf> {
129 loop {
130 let cf = dir.join("containers/devshell-vm/Containerfile");
131 if cf.is_file() {
132 return Some(dir);
133 }
134 if !dir.pop() {
135 break;
136 }
137 }
138 None
139}
140
141#[cfg_attr(not(windows), allow(dead_code))]
143#[cfg(feature = "beta-vm")]
144pub(crate) fn devshell_repo_root_with_containerfile() -> Option<std::path::PathBuf> {
145 let dir = std::env::current_dir().ok()?;
146 devshell_repo_root_walk(dir)
147}
148
149#[cfg_attr(not(windows), allow(dead_code))]
151#[cfg(feature = "beta-vm")]
152pub(crate) fn devshell_repo_root_from_path(start: &std::path::Path) -> Option<std::path::PathBuf> {
153 devshell_repo_root_walk(start.to_path_buf())
154}
155
156fn default_backend_for_release() -> String {
157 #[cfg(all(windows, feature = "beta-vm"))]
158 {
159 return "beta".to_string();
160 }
161 #[cfg(unix)]
162 {
163 "lima".to_string()
164 }
165 #[cfg(not(any(unix, all(windows, feature = "beta-vm"))))]
166 {
167 "host".to_string()
168 }
169}
170
171fn vm_enabled_from_env() -> bool {
172 if cfg!(test) {
173 return std::env::var(ENV_DEVSHELL_VM)
174 .map(|s| truthy(&s))
175 .unwrap_or(false);
176 }
177 match std::env::var(ENV_DEVSHELL_VM) {
178 Err(_) => true,
179 Ok(s) if s.trim().is_empty() => false,
180 Ok(s) if falsy(&s) => false,
181 Ok(s) => truthy(&s),
182 }
183}
184
185fn backend_from_env() -> String {
186 let from_var = std::env::var(ENV_DEVSHELL_VM_BACKEND)
187 .ok()
188 .map(|s| s.trim().to_string())
189 .filter(|s| !s.is_empty());
190 if let Some(b) = from_var {
191 return b;
192 }
193 if cfg!(test) {
194 "auto".to_string()
195 } else {
196 default_backend_for_release()
197 }
198}
199
200impl VmConfig {
201 #[must_use]
203 pub fn from_env() -> Self {
204 let enabled = vm_enabled_from_env();
205
206 let backend = backend_from_env();
207
208 let eager_start = std::env::var(ENV_DEVSHELL_VM_EAGER)
209 .map(|s| truthy(&s))
210 .unwrap_or(false);
211
212 let lima_instance = std::env::var(ENV_DEVSHELL_VM_LIMA_INSTANCE)
213 .ok()
214 .map(|s| s.trim().to_string())
215 .filter(|s| !s.is_empty())
216 .unwrap_or_else(|| "devshell-rust".to_string());
217
218 Self {
219 enabled,
220 backend,
221 eager_start,
222 lima_instance,
223 }
224 }
225
226 #[must_use]
228 pub fn disabled() -> Self {
229 Self {
230 enabled: false,
231 backend: String::new(),
232 eager_start: false,
233 lima_instance: String::new(),
234 }
235 }
236
237 #[must_use]
239 pub fn use_host_sandbox(&self) -> bool {
240 let b = self.backend.to_ascii_lowercase();
241 b == "host" || b == "auto" || b.is_empty()
242 }
243
244 #[must_use]
250 pub fn workspace_mode_effective(&self) -> WorkspaceMode {
251 let requested = workspace_mode_from_env();
252 if matches!(requested, WorkspaceMode::Sync) {
253 return WorkspaceMode::Sync;
254 }
255
256 let effective = if !self.enabled || self.use_host_sandbox() {
257 WorkspaceMode::Sync
258 } else {
259 let b = self.backend.to_ascii_lowercase();
260 if b == "lima" || b == "beta" {
261 WorkspaceMode::Guest
262 } else {
263 WorkspaceMode::Sync
264 }
265 };
266
267 if matches!(requested, WorkspaceMode::Guest)
268 && matches!(effective, WorkspaceMode::Sync)
269 && !cfg!(test)
270 {
271 eprintln!(
272 "dev_shell: DEVSHELL_VM_WORKSPACE_MODE=guest requires VM enabled and backend lima or beta; using sync mode."
273 );
274 }
275
276 effective
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use std::sync::{Mutex, OnceLock};
283
284 use super::*;
285
286 fn vm_env_lock() -> std::sync::MutexGuard<'static, ()> {
287 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
288 LOCK.get_or_init(|| Mutex::new(()))
289 .lock()
290 .unwrap_or_else(|e| e.into_inner())
291 }
292
293 fn set_env(key: &str, val: Option<&str>) {
294 match val {
295 Some(v) => std::env::set_var(key, v),
296 None => std::env::remove_var(key),
297 }
298 }
299
300 #[test]
301 fn from_env_devshell_vm_on() {
302 let _g = vm_env_lock();
303 let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
304 let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
305 set_env(ENV_DEVSHELL_VM, Some("on"));
306 set_env(ENV_DEVSHELL_VM_BACKEND, None);
307 let c = VmConfig::from_env();
308 assert!(c.enabled);
309 assert_eq!(c.backend, "auto");
310 set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
311 set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
312 }
313
314 #[test]
315 fn from_env_defaults_off() {
316 let _g = vm_env_lock();
317 let old = std::env::var(ENV_DEVSHELL_VM).ok();
318 set_env(ENV_DEVSHELL_VM, None);
319 let c = VmConfig::from_env();
320 assert!(!c.enabled);
321 set_env(ENV_DEVSHELL_VM, old.as_deref());
322 }
323
324 #[test]
325 fn from_env_explicit_off_disables_vm() {
326 let _g = vm_env_lock();
327 let old = std::env::var(ENV_DEVSHELL_VM).ok();
328 set_env(ENV_DEVSHELL_VM, Some("off"));
329 let c = VmConfig::from_env();
330 assert!(!c.enabled);
331 set_env(ENV_DEVSHELL_VM, old.as_deref());
332 }
333
334 #[test]
335 fn use_host_sandbox_lima_false() {
336 let mut c = VmConfig::disabled();
337 c.backend = "lima".to_string();
338 assert!(!c.use_host_sandbox());
339 }
340
341 #[test]
342 fn workspace_mode_from_env_unset_defaults_sync() {
343 let _g = vm_env_lock();
344 let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
345 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, None);
346 assert_eq!(workspace_mode_from_env(), WorkspaceMode::Sync);
347 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
348 }
349
350 #[test]
351 fn workspace_mode_from_env_guest() {
352 let _g = vm_env_lock();
353 let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
354 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
355 assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
356 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("GUEST"));
357 assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
358 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
359 }
360
361 #[test]
362 fn workspace_mode_effective_guest_plus_host_sandbox_forces_sync() {
363 let _g = vm_env_lock();
364 let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
365 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
366 let mut c = VmConfig::disabled();
367 c.enabled = true;
368 c.backend = "host".to_string();
369 assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
370 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
371 }
372
373 #[test]
374 fn workspace_mode_effective_guest_vm_off_forces_sync() {
375 let _g = vm_env_lock();
376 let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
377 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
378 let mut c = VmConfig::disabled();
379 c.enabled = false;
380 c.backend = "lima".to_string();
381 assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
382 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
383 }
384
385 #[test]
386 fn workspace_mode_effective_guest_lima_enabled() {
387 let _g = vm_env_lock();
388 let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
389 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
390 let mut c = VmConfig::disabled();
391 c.enabled = true;
392 c.backend = "lima".to_string();
393 assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Guest);
394 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
395 }
396
397 #[test]
398 fn workspace_mode_effective_sync_env_ignores_backend() {
399 let _g = vm_env_lock();
400 let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
401 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("sync"));
402 let mut c = VmConfig::disabled();
403 c.enabled = true;
404 c.backend = "lima".to_string();
405 assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
406 set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
407 }
408
409 #[cfg(unix)]
410 #[test]
411 fn try_from_config_lima_depends_on_limactl_in_path() {
412 use super::super::{SessionHolder, VmError};
413 use crate::devshell::sandbox;
414
415 let _g = vm_env_lock();
416 let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
417 let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
418 set_env(ENV_DEVSHELL_VM, Some("1"));
419 set_env(ENV_DEVSHELL_VM_BACKEND, Some("lima"));
420 let c = VmConfig::from_env();
421 assert!(c.enabled);
422 assert!(!c.use_host_sandbox());
423 let r = SessionHolder::try_from_config(&c);
424 match sandbox::find_in_path("limactl") {
425 Some(_) => assert!(
426 matches!(r, Ok(SessionHolder::Gamma(_))),
427 "expected Gamma session when limactl is in PATH, got {r:?}"
428 ),
429 None => assert!(
430 matches!(r, Err(VmError::Lima(_))),
431 "expected Lima error when limactl missing, got {r:?}"
432 ),
433 }
434 set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
435 set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
436 }
437
438 #[cfg(not(unix))]
439 #[test]
440 fn try_from_config_lima_errors_on_non_unix() {
441 use super::super::{SessionHolder, VmError};
442
443 let mut c = VmConfig::disabled();
444 c.enabled = true;
445 c.backend = "lima".to_string();
446 c.lima_instance = "devshell-rust".to_string();
447 let r = SessionHolder::try_from_config(&c);
448 assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
449 }
450
451 #[cfg(all(unix, not(feature = "beta-vm")))]
452 #[test]
453 fn try_from_config_beta_requires_feature_flag() {
454 use super::super::{SessionHolder, VmError};
455
456 let mut c = VmConfig::disabled();
457 c.enabled = true;
458 c.backend = "beta".to_string();
459 c.lima_instance = "devshell-rust".to_string();
460 let r = SessionHolder::try_from_config(&c);
461 let Err(VmError::BackendNotImplemented(msg)) = r else {
462 panic!("expected BackendNotImplemented, got {r:?}");
463 };
464 assert!(
465 msg.contains("beta-vm"),
466 "message should mention beta-vm: {msg}"
467 );
468 }
469
470 #[cfg(all(not(unix), not(feature = "beta-vm")))]
472 #[test]
473 fn try_from_config_beta_errors_without_beta_vm_feature() {
474 use super::super::{SessionHolder, VmError};
475
476 let mut c = VmConfig::disabled();
477 c.enabled = true;
478 c.backend = "beta".to_string();
479 c.lima_instance = "devshell-rust".to_string();
480 let r = SessionHolder::try_from_config(&c);
481 assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
482 }
483}