use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use crate::config::{Result, TestbedError};
pub const UTMCTL: &str = "/Applications/UTM.app/Contents/MacOS/utmctl";
pub const MIN_UTM_VERSION: &str = "4.6.5";
#[derive(Debug, Clone)]
pub struct VmEntry {
pub uuid: String,
pub status: String,
pub name: String,
}
pub fn check_utm_version() -> Result<Option<String>> {
let plist_path = "/Applications/UTM.app/Contents/Info.plist";
if !std::path::Path::new(plist_path).exists() {
return Ok(None);
}
let output = Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleShortVersionString", plist_path])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("checking UTM version: {e}"),
})?;
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
let warning = if version_is_older(&version, MIN_UTM_VERSION) {
Some(format!(
"UTM {version} found, minimum is {MIN_UTM_VERSION} — update recommended"
))
} else {
None
};
Ok(warning)
} else {
Ok(None)
}
}
pub fn installed_utm_version() -> Option<String> {
let plist = "/Applications/UTM.app/Contents/Info.plist";
let out = Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleShortVersionString", plist])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn ensure_utm() -> Result<Vec<String>> {
let mut warnings = Vec::new();
if !std::path::Path::new(UTMCTL).exists() {
return Err(TestbedError::Qcow2Error {
message: format!(
"UTM not installed at /Applications/UTM.app. Install via: brew install --cask utm"
),
});
}
let output = Command::new(UTMCTL)
.args(["list"])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl list failed: {e}"),
})?;
if !output.status.success() {
eprintln!(" UTM.app not responsive — launching...");
Command::new("open")
.args(["-g", "/Applications/UTM.app"])
.status()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("launching UTM.app: {e}"),
})?;
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
thread::sleep(Duration::from_secs(1));
if Command::new(UTMCTL)
.arg("list")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!(" UTM ready");
break;
}
}
let output = Command::new(UTMCTL)
.args(["list"])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl list failed after launch: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: "UTM did not become ready after 30s".to_string(),
});
}
}
if let Some(warning) = check_utm_version()? {
warnings.push(warning);
}
Ok(warnings)
}
pub fn list_vms() -> Result<Vec<VmEntry>> {
let output = Command::new(UTMCTL)
.args(["list"])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl list failed: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!(
"utmctl list failed: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 {
entries.push(VmEntry {
uuid: parts[0].trim().to_string(),
status: parts[1].trim().to_string(),
name: parts[2].trim().to_string(),
});
}
}
Ok(entries)
}
pub fn find_vm_by_name(name: &str) -> Result<Option<VmEntry>> {
let vms = list_vms()?;
Ok(vms.into_iter().find(|vm| vm.name == name))
}
pub fn get_vm_name(bundle_path: &std::path::Path) -> Result<String> {
let plist = bundle_path.join("config.plist");
let output = Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :Name", plist.to_str().unwrap()])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("PlistBuddy read failed: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!(
"PlistBuddy read Name failed: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn start_vm(name: &str) -> Result<()> {
if let Some(e) = list_vms()?.into_iter().find(|e| e.name == name) {
if e.status == "started" {
return Ok(());
}
}
for attempt in 1..=3 {
let output = Command::new(UTMCTL)
.args(["start", name])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl start {name} failed: {e}"),
})?;
if output.status.success() {
return Ok(());
}
if attempt < 3 {
thread::sleep(Duration::from_secs(5));
}
}
Err(TestbedError::Qcow2Error {
message: format!("failed to start '{name}' after 3 attempts"),
})
}
pub fn stop_vm(name: &str) -> Result<()> {
let running = list_vms()?
.into_iter()
.any(|e| e.name == name && e.status == "started");
if !running {
return Ok(());
}
let output = Command::new(UTMCTL)
.args(["stop", name])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl stop {name} failed: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!(
"utmctl stop {name} failed: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
thread::sleep(Duration::from_secs(5));
Ok(())
}
pub fn delete_vm(uuid: &str) -> Result<()> {
let output = Command::new(UTMCTL)
.args(["delete", uuid])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("utmctl delete {uuid} failed: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!(
"utmctl delete {uuid} failed: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
Ok(())
}
pub fn is_vm_running(name: &str) -> Result<bool> {
if let Some(vm) = find_vm_by_name(name)? {
Ok(vm.status == "started")
} else {
Ok(false)
}
}
pub fn wait_for_vm(uuid: &str, timeout_secs: u64) -> Result<bool> {
let start = Instant::now();
loop {
if start.elapsed().as_secs() > timeout_secs {
return Ok(false);
}
if let Ok(vms) = list_vms() {
if vms.iter().any(|vm| vm.uuid == uuid) {
return Ok(true);
}
}
thread::sleep(Duration::from_secs(1));
}
}
pub fn version_is_older(actual: &str, min: &str) -> bool {
let parse = |v: &str| -> (u32, u32, u32) {
let mut parts = v.split('.').map(|s| s.parse::<u32>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
};
let (a1, a2, a3) = parse(actual);
let (m1, m2, m3) = parse(min);
(a1, a2, a3) < (m1, m2, m3)
}