use std::fs;
use std::path::Path;
use crate::config::{GuestOs, Result, TestbedError};
pub fn init(project_root: &Path, vm_names: &[String]) -> Result<()> {
let testbed_dir = project_root.join(".testbed");
let toml_path = project_root.join("testbed.toml");
if toml_path.exists() {
return Err(TestbedError::Qcow2Error {
message: format!(
"{} already exists — delete it or edit existing config",
toml_path.display()
),
});
}
fs::create_dir_all(&testbed_dir).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating {}: {e}", testbed_dir.display()),
})?;
for vm_name in vm_names {
let vm_dir = testbed_dir.join(vm_name);
for subdir in &["logs", "artifacts", "state"] {
fs::create_dir_all(vm_dir.join(subdir)).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating {vm_name}/{subdir}/: {e}"),
})?;
}
generate_scripts(&testbed_dir, vm_name)?;
}
fs::create_dir_all(testbed_dir.join("artifacts")).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating artifacts/: {e}"),
})?;
let toml_content = generate_testbed_toml(vm_names);
fs::write(&toml_path, &toml_content).map_err(|e| TestbedError::Qcow2Error {
message: format!("writing testbed.toml: {e}"),
})?;
let gitignore = generate_gitignore();
fs::write(testbed_dir.join(".gitignore"), &gitignore).map_err(|e| TestbedError::Qcow2Error {
message: format!("writing .gitignore: {e}"),
})?;
for vm_name in vm_names {
generate_scripts(&testbed_dir, vm_name)?;
}
Ok(())
}
fn guest_mount_path(vm_name: &str) -> &'static str {
let lower = vm_name.to_lowercase();
if lower.contains("windows") {
"C:/Users/vagrant/project"
} else if lower.contains("macos") {
"/Users/vagrant/project"
} else {
"/mnt/project"
}
}
pub fn generate_testbed_toml(vm_names: &[String]) -> String {
let mut out = String::from(
"# testbed.toml — project-level VM configuration\n\
# Generated by `ewe_platform testbed init`\n\n",
);
for name in vm_names {
let guest_path = guest_mount_path(name);
out.push_str(&format!(
"[[vms]]\n\
name = \"{name}\"\n\
profile = \"{name}\"\n\
mount = {{ host_path = \".\", guest_path = \"{guest_path}\", readonly = false }}\n\n",
));
}
let has_macos = vm_names.iter().any(|n| n.contains("macos"));
if !has_macos {
out.push_str(
"# macOS VM — uses pre-baked image from store or native IPSW fallback\n\
[[vms]]\n\
name = \"macos-build\"\n\
profile = \"macos-build\"\n\
mount = {{ host_path = \".\", guest_path = \"/Users/vagrant/project\", readonly = false }}\n\n",
);
}
out.push_str(
"# Artifact mirroring\n\
[artifacts]\n\
sync = true # Mirror build outputs\n\n",
);
out.push_str(
"# Image stores — ordered sources for VM disk images.\n\
# First store with a matching image wins.\n\n\
# macOS pre-baked tarball (BaseSystem.qcow2 + opencore.qcow2 + macOS-data.qcow2)\n\
[[image_stores]]\n\
name = \"macos-build\"\n\
type = \"http\"\n\
destination = \"https://pub-abc123.r2.dev/testbed-images\"\n\
key_prefix = \"macos-build/\"\n\n",
);
out.push_str(
"# Vagrant Cloud — default fallback for Windows/Linux images\n\
[[image_stores]]\n\
name = \"vagrant_cloud\"\n\
type = \"vagrant\"\n\
registry = \"libvirt\"\n\n",
);
let local_store = dirs::home_dir()
.map(|h| h.join(".testbed"))
.unwrap_or_default()
.to_string_lossy()
.into_owned();
out.push_str(&format!(
"# Local image store — user home directory\n\
[[image_stores]]\n\
name = \"local\"\n\
type = \"local\"\n\
destination = \"{local_store}\"\n\
key_prefix = \"\"\n\n",
));
out
}
pub fn generate_gitignore() -> String {
String::from(
"# $PWD/.testbed/.gitignore — auto-generated by testbed init\n\
# Commit testbed.toml and scripts/; ignore ephemeral data.\n\n\
# VM state — host-specific PIDs, ports, disk paths\n\
/*/state/\n\n\
# Build/run logs — can be large, regenerated on next build\n\
/*/logs/\n\n\
# Build artifacts — live in host's target/ directory, symlinked here\n\
/*/artifacts/\n\
/artifacts/\n\n\
# Mount points — symlink to project root\n\
/mounts/\n\n\
# Keep: testbed.toml (project config) ✓\n\
# Keep: scripts/ (user custom startup/shutdown scripts) ✓\n",
)
}
fn generate_scripts(testbed_dir: &Path, vm_name: &str) -> Result<()> {
let scripts_dir = testbed_dir.join("scripts").join(vm_name);
let is_windows = vm_name.to_lowercase().contains("windows");
if is_windows {
let startup = scripts_dir.join("startup");
fs::create_dir_all(&startup).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating {startup:?}: {e}"),
})?;
fs::write(
startup.join("00-defender.ps1"),
BUILTIN_DEFENDER_EXCLUSIONS,
)
.map_err(|e| TestbedError::Qcow2Error {
message: format!("writing 00-defender.ps1: {e}"),
})?;
} else {
let startup = scripts_dir.join("startup");
fs::create_dir_all(&startup).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating {startup:?}: {e}"),
})?;
fs::write(
startup.join("00-mount.sh"),
BUILTIN_MOUNT_CHECK,
)
.map_err(|e| TestbedError::Qcow2Error {
message: format!("writing 00-mount.sh: {e}"),
})?;
}
fs::create_dir_all(scripts_dir.join("shutdown")).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating shutdown/: {e}"),
})?;
Ok(())
}
pub const BUILTIN_DEFENDER_EXCLUSIONS: &str = r#"# Windows Defender exclusions for development directories
# Edit or delete if you don't need these
Add-MpPreference -ExclusionPath "C:\Users\vagrant\project" -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath "C:\Users\vagrant\.cargo" -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath "C:\Users\vagrant\.rustup" -ErrorAction SilentlyContinue
"#;
pub const BUILTIN_MOUNT_CHECK: &str = r#"# Verify project mount is accessible
if mount | grep -q "/mnt/project"; then
echo "✓ /mnt/project mounted"
else
echo "⚠ /mnt/project not mounted, attempting mount..."
sudo mount -t 9p -o trans=virtio,version=9p2000.L project /mnt/project 2>/dev/null || true
fi
"#;
pub fn run_startup_scripts(vm_name: &str, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
let scripts_dir = Path::new(".testbed").join("scripts").join(vm_name).join("startup");
run_scripts_dir(&scripts_dir, os, ssh_port, user)
}
pub fn run_shutdown_scripts(vm_name: &str, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
let scripts_dir = Path::new(".testbed").join("scripts").join(vm_name).join("shutdown");
run_scripts_dir(&scripts_dir, os, ssh_port, user)
}
fn run_scripts_dir(dir: &Path, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
if !dir.exists() {
return Ok(());
}
let mut entries: Vec<_> = fs::read_dir(dir)
.map_err(|e| TestbedError::SshFailed {
port: ssh_port,
source: anyhow::anyhow!("reading {dir:?}: {e}"),
})?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in &entries {
let path = entry.path();
let content = fs::read_to_string(&path).map_err(|e| TestbedError::SshFailed {
port: ssh_port,
source: anyhow::anyhow!("reading {:?}: {e}", path.display()),
})?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let (output, code) = match (os, ext) {
(GuestOs::Windows, "ps1") => {
let mut session = crate::ssh::connect_from_port(ssh_port, user, os)?;
crate::ssh::exec_ps_windows(&mut session, &content)?
}
_ => {
let mut session = crate::ssh::connect_from_port(ssh_port, user, os)?;
let wrapped = format!("nu -c {}", shell_quote(&content));
crate::ssh::exec_with_exit(&mut session, &wrapped)?
}
};
if code != 0 {
return Err(TestbedError::SshFailed {
port: ssh_port,
source: anyhow::anyhow!(
"script {:?} failed with exit code {code}\n{output}",
path.display()
),
});
}
}
Ok(())
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}