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