#![allow(clippy::pedantic, clippy::nursery)]
pub const ENV_DEVSHELL_VM: &str = "DEVSHELL_VM";
pub const ENV_DEVSHELL_VM_BACKEND: &str = "DEVSHELL_VM_BACKEND";
pub const ENV_DEVSHELL_VM_EAGER: &str = "DEVSHELL_VM_EAGER";
pub const ENV_DEVSHELL_VM_LIMA_INSTANCE: &str = "DEVSHELL_VM_LIMA_INSTANCE";
pub const ENV_DEVSHELL_VM_SOCKET: &str = "DEVSHELL_VM_SOCKET";
pub const ENV_DEVSHELL_VM_BETA_SESSION_STAGING: &str = "DEVSHELL_VM_BETA_SESSION_STAGING";
pub const ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP: &str = "DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP";
pub const ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME: &str = "DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME";
pub const ENV_DEVSHELL_VM_WORKSPACE_MODE: &str = "DEVSHELL_VM_WORKSPACE_MODE";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceMode {
Sync,
Guest,
}
#[must_use]
pub fn workspace_mode_from_env() -> WorkspaceMode {
match std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE) {
Ok(s) if s.trim().eq_ignore_ascii_case("guest") => WorkspaceMode::Guest,
_ => WorkspaceMode::Sync,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VmConfig {
pub enabled: bool,
pub backend: String,
pub eager_start: bool,
pub lima_instance: String,
}
fn truthy(s: &str) -> bool {
let s = s.trim();
s == "1"
|| s.eq_ignore_ascii_case("true")
|| s.eq_ignore_ascii_case("yes")
|| s.eq_ignore_ascii_case("on")
}
fn falsy(s: &str) -> bool {
let s = s.trim();
s == "0"
|| s.eq_ignore_ascii_case("false")
|| s.eq_ignore_ascii_case("no")
|| s.eq_ignore_ascii_case("off")
}
#[cfg(all(windows, feature = "beta-vm"))]
pub(crate) fn devshell_repo_root_with_containerfile() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let cf = dir.join("containers/devshell-vm/Containerfile");
if cf.is_file() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
fn default_backend_for_release() -> String {
#[cfg(all(windows, feature = "beta-vm"))]
{
return "beta".to_string();
}
#[cfg(unix)]
{
"lima".to_string()
}
#[cfg(not(any(unix, all(windows, feature = "beta-vm"))))]
{
"host".to_string()
}
}
fn vm_enabled_from_env() -> bool {
if cfg!(test) {
return std::env::var(ENV_DEVSHELL_VM)
.map(|s| truthy(&s))
.unwrap_or(false);
}
match std::env::var(ENV_DEVSHELL_VM) {
Err(_) => true,
Ok(s) if s.trim().is_empty() => false,
Ok(s) if falsy(&s) => false,
Ok(s) => truthy(&s),
}
}
fn backend_from_env() -> String {
let from_var = std::env::var(ENV_DEVSHELL_VM_BACKEND)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(b) = from_var {
return b;
}
if cfg!(test) {
"auto".to_string()
} else {
default_backend_for_release()
}
}
impl VmConfig {
#[must_use]
pub fn from_env() -> Self {
let enabled = vm_enabled_from_env();
let backend = backend_from_env();
let eager_start = std::env::var(ENV_DEVSHELL_VM_EAGER)
.map(|s| truthy(&s))
.unwrap_or(false);
let lima_instance = std::env::var(ENV_DEVSHELL_VM_LIMA_INSTANCE)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "devshell-rust".to_string());
Self {
enabled,
backend,
eager_start,
lima_instance,
}
}
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
backend: String::new(),
eager_start: false,
lima_instance: String::new(),
}
}
#[must_use]
pub fn use_host_sandbox(&self) -> bool {
let b = self.backend.to_ascii_lowercase();
b == "host" || b == "auto" || b.is_empty()
}
#[must_use]
pub fn workspace_mode_effective(&self) -> WorkspaceMode {
let requested = workspace_mode_from_env();
if matches!(requested, WorkspaceMode::Sync) {
return WorkspaceMode::Sync;
}
let effective = if !self.enabled || self.use_host_sandbox() {
WorkspaceMode::Sync
} else {
let b = self.backend.to_ascii_lowercase();
if b == "lima" || b == "beta" {
WorkspaceMode::Guest
} else {
WorkspaceMode::Sync
}
};
if matches!(requested, WorkspaceMode::Guest)
&& matches!(effective, WorkspaceMode::Sync)
&& !cfg!(test)
{
eprintln!(
"dev_shell: DEVSHELL_VM_WORKSPACE_MODE=guest requires VM enabled and backend lima or beta; using sync mode."
);
}
effective
}
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use super::*;
fn vm_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn set_env(key: &str, val: Option<&str>) {
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn from_env_devshell_vm_on() {
let _g = vm_env_lock();
let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
set_env(ENV_DEVSHELL_VM, Some("on"));
set_env(ENV_DEVSHELL_VM_BACKEND, None);
let c = VmConfig::from_env();
assert!(c.enabled);
assert_eq!(c.backend, "auto");
set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
}
#[test]
fn from_env_defaults_off() {
let _g = vm_env_lock();
let old = std::env::var(ENV_DEVSHELL_VM).ok();
set_env(ENV_DEVSHELL_VM, None);
let c = VmConfig::from_env();
assert!(!c.enabled);
set_env(ENV_DEVSHELL_VM, old.as_deref());
}
#[test]
fn from_env_explicit_off_disables_vm() {
let _g = vm_env_lock();
let old = std::env::var(ENV_DEVSHELL_VM).ok();
set_env(ENV_DEVSHELL_VM, Some("off"));
let c = VmConfig::from_env();
assert!(!c.enabled);
set_env(ENV_DEVSHELL_VM, old.as_deref());
}
#[test]
fn use_host_sandbox_lima_false() {
let mut c = VmConfig::disabled();
c.backend = "lima".to_string();
assert!(!c.use_host_sandbox());
}
#[test]
fn workspace_mode_from_env_unset_defaults_sync() {
let _g = vm_env_lock();
let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, None);
assert_eq!(workspace_mode_from_env(), WorkspaceMode::Sync);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
}
#[test]
fn workspace_mode_from_env_guest() {
let _g = vm_env_lock();
let old = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("GUEST"));
assert_eq!(workspace_mode_from_env(), WorkspaceMode::Guest);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old.as_deref());
}
#[test]
fn workspace_mode_effective_guest_plus_host_sandbox_forces_sync() {
let _g = vm_env_lock();
let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "host".to_string();
assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
}
#[test]
fn workspace_mode_effective_guest_vm_off_forces_sync() {
let _g = vm_env_lock();
let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
let mut c = VmConfig::disabled();
c.enabled = false;
c.backend = "lima".to_string();
assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
}
#[test]
fn workspace_mode_effective_guest_lima_enabled() {
let _g = vm_env_lock();
let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("guest"));
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "lima".to_string();
assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Guest);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
}
#[test]
fn workspace_mode_effective_sync_env_ignores_backend() {
let _g = vm_env_lock();
let old_w = std::env::var(ENV_DEVSHELL_VM_WORKSPACE_MODE).ok();
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, Some("sync"));
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "lima".to_string();
assert_eq!(c.workspace_mode_effective(), WorkspaceMode::Sync);
set_env(ENV_DEVSHELL_VM_WORKSPACE_MODE, old_w.as_deref());
}
#[cfg(unix)]
#[test]
fn try_from_config_lima_depends_on_limactl_in_path() {
use super::super::{SessionHolder, VmError};
use crate::devshell::sandbox;
let _g = vm_env_lock();
let old_vm = std::env::var(ENV_DEVSHELL_VM).ok();
let old_b = std::env::var(ENV_DEVSHELL_VM_BACKEND).ok();
set_env(ENV_DEVSHELL_VM, Some("1"));
set_env(ENV_DEVSHELL_VM_BACKEND, Some("lima"));
let c = VmConfig::from_env();
assert!(c.enabled);
assert!(!c.use_host_sandbox());
let r = SessionHolder::try_from_config(&c);
match sandbox::find_in_path("limactl") {
Some(_) => assert!(
matches!(r, Ok(SessionHolder::Gamma(_))),
"expected Gamma session when limactl is in PATH, got {r:?}"
),
None => assert!(
matches!(r, Err(VmError::Lima(_))),
"expected Lima error when limactl missing, got {r:?}"
),
}
set_env(ENV_DEVSHELL_VM, old_vm.as_deref());
set_env(ENV_DEVSHELL_VM_BACKEND, old_b.as_deref());
}
#[cfg(not(unix))]
#[test]
fn try_from_config_lima_errors_on_non_unix() {
use super::super::{SessionHolder, VmError};
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "lima".to_string();
c.lima_instance = "devshell-rust".to_string();
let r = SessionHolder::try_from_config(&c);
assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
}
#[cfg(all(unix, not(feature = "beta-vm")))]
#[test]
fn try_from_config_beta_requires_feature_flag() {
use super::super::{SessionHolder, VmError};
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "beta".to_string();
c.lima_instance = "devshell-rust".to_string();
let r = SessionHolder::try_from_config(&c);
let Err(VmError::BackendNotImplemented(msg)) = r else {
panic!("expected BackendNotImplemented, got {r:?}");
};
assert!(
msg.contains("beta-vm"),
"message should mention beta-vm: {msg}"
);
}
#[cfg(all(not(unix), not(feature = "beta-vm")))]
#[test]
fn try_from_config_beta_errors_without_beta_vm_feature() {
use super::super::{SessionHolder, VmError};
let mut c = VmConfig::disabled();
c.enabled = true;
c.backend = "beta".to_string();
c.lima_instance = "devshell-rust".to_string();
let r = SessionHolder::try_from_config(&c);
assert!(matches!(r, Err(VmError::BackendNotImplemented(_))));
}
}