use anyhow::Result;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::qemu_config::*;
use crate::commands::qemu_img;
pub fn parse_launch_script(script_path: &Path, content: &str) -> Result<QemuConfig> {
let mut config = QemuConfig {
raw_script: content.to_string(),
..Default::default()
};
let vm_dir = script_path.parent().unwrap_or(Path::new("."));
if let Some(emulator) = extract_emulator(content) {
config.emulator = emulator;
}
if let Some(mem) = extract_memory(content) {
config.memory_mb = mem;
}
if let Some(cores) = extract_cpu_cores(content) {
config.cpu_cores = cores;
}
config.cpu_model = extract_cpu_model(content);
config.machine = extract_machine(content);
if let Some(vga) = extract_vga(content) {
config.vga = vga;
}
config.audio_devices = extract_audio_devices(content);
config.enable_kvm = content.contains("-enable-kvm") || content.contains("-accel kvm");
config.uefi = content.contains("OVMF") || content.contains("-bios") && content.contains("efi");
config.tpm = content.contains("-tpmdev") || content.contains("swtpm");
config.bios_path = extract_bios_path(content, vm_dir);
config.disks = extract_disks(content, vm_dir);
config.network = extract_network(content);
config.extra_args = extract_extra_args(content);
Ok(config)
}
fn extract_emulator(content: &str) -> Option<QemuEmulator> {
let emulators = [
"qemu-system-x86_64",
"qemu-system-i386",
"qemu-system-ppc",
"qemu-system-m68k",
"qemu-system-arm",
"qemu-system-aarch64",
];
for emulator in emulators {
if content.contains(emulator) {
return Some(QemuEmulator::from_command(emulator));
}
}
None
}
fn extract_memory(content: &str) -> Option<u32> {
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-m ") {
let rest = &line[idx + 3..];
let value: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(mem) = value.parse::<u32>() {
if rest.contains('G') {
return Some(mem * 1024);
}
if mem < 64 {
return Some(mem * 1024);
}
return Some(mem);
}
}
}
None
}
fn extract_cpu_cores(content: &str) -> Option<u32> {
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-smp ") {
let rest = &line[idx + 5..];
let value: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(cores) = value.parse::<u32>() {
return Some(cores);
}
}
}
None
}
fn extract_cpu_model(content: &str) -> Option<String> {
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-cpu ") {
let rest = &line[idx + 5..];
let model: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != '\\')
.collect();
if !model.is_empty() {
return Some(model);
}
}
}
None
}
fn extract_machine(content: &str) -> Option<String> {
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-M ") {
let rest = &line[idx + 3..];
let machine: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != '\\')
.collect();
if !machine.is_empty() {
return Some(machine);
}
}
if let Some(idx) = line.find("-machine ") {
let rest = &line[idx + 9..];
let machine: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != ',' && *c != '\\')
.collect();
if !machine.is_empty() {
return Some(machine);
}
}
}
None
}
fn extract_vga(content: &str) -> Option<VgaType> {
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-vga ") {
let rest = &line[idx + 5..];
let vga: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != '\\')
.collect();
if !vga.is_empty() {
return Some(VgaType::from_str(&vga));
}
}
}
None
}
fn extract_audio_devices(content: &str) -> Vec<AudioDevice> {
let mut devices = Vec::new();
if content.contains("sb16") || content.contains("SB16") {
devices.push(AudioDevice::Sb16);
}
if content.contains("ac97") || content.contains("AC97") {
devices.push(AudioDevice::Ac97);
}
if content.contains("intel-hda") || content.contains("hda-duplex") {
devices.push(AudioDevice::Hda);
}
if content.contains("es1370") {
devices.push(AudioDevice::Es1370);
}
devices
}
fn extract_shell_variables(content: &str, vm_dir: &Path) -> HashMap<String, String> {
let mut vars = HashMap::new();
let vm_dir_str = vm_dir.to_string_lossy().to_string();
vars.insert("VM_DIR".to_string(), vm_dir_str.clone());
vars.insert("DIR".to_string(), vm_dir_str.clone());
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim();
if !name.is_empty()
&& name.chars().all(|c| c.is_alphanumeric() || c == '_')
&& !name.chars().next().unwrap_or('0').is_ascii_digit()
{
let value_part = trimmed[eq_pos + 1..].trim();
let value = extract_quoted_value(value_part);
let expanded = expand_variables(&value, &vars, vm_dir);
vars.insert(name.to_string(), expanded);
}
}
}
vars
}
fn extract_quoted_value(s: &str) -> String {
if s.starts_with('"') {
let chars: Vec<char> = s.chars().collect();
let mut depth = 0;
let mut end_idx = s.len() - 1;
for (i, &c) in chars.iter().enumerate().skip(1) {
match c {
'(' if i > 0 && chars[i - 1] == '$' => depth += 1,
')' if depth > 0 => depth -= 1,
'"' if depth == 0 => {
end_idx = i;
break;
}
_ => {}
}
}
s[1..end_idx].to_string()
} else if let Some(stripped) = s.strip_prefix('\'') {
if let Some(end) = stripped.find('\'') {
stripped[..end].to_string()
} else {
stripped.to_string()
}
} else {
s.chars()
.take_while(|c| !c.is_whitespace() && *c != '#')
.collect()
}
}
fn expand_variables(s: &str, vars: &HashMap<String, String>, vm_dir: &Path) -> String {
let mut result = s.to_string();
let vm_dir_str = vm_dir.to_string_lossy();
while result.contains("$(dirname") {
if let Some(start) = result.find("$(dirname") {
let mut depth = 0;
let mut end = start;
for (i, c) in result[start..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = start + i;
break;
}
}
_ => {}
}
}
if end > start {
result = format!("{}{}{}", &result[..start], vm_dir_str, &result[end + 1..]);
} else {
break;
}
} else {
break;
}
}
for (name, value) in vars {
result = result.replace(&format!("${{{}}}", name), value);
}
for (name, value) in vars {
result = result.replace(&format!("${}", name), value);
}
if result.contains("$HOME") || result.contains("${HOME}") {
if let Some(home) = dirs::home_dir() {
let home_str = home.to_string_lossy();
result = result.replace("${HOME}", &home_str);
result = result.replace("$HOME", &home_str);
}
}
result
}
fn extract_disks(content: &str, vm_dir: &Path) -> Vec<DiskConfig> {
let mut disks = Vec::new();
let vars = extract_shell_variables(content, vm_dir);
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
for hd in ["hda", "hdb", "hdc", "hdd"] {
let pattern = format!("-{} ", hd);
if let Some(idx) = line.find(&pattern) {
let rest = &line[idx + pattern.len()..];
if let Some(path) = extract_path_from_arg(rest) {
let expanded = expand_variables(&path, &vars, vm_dir);
let full_path = resolve_path(&expanded, vm_dir);
let format = guess_disk_format(&full_path);
disks.push(DiskConfig {
path: full_path,
format,
interface: "ide".to_string(),
});
}
}
}
if line.contains("-drive") && line.contains("file=") {
if let Some(path) = extract_drive_file(line) {
let expanded = expand_variables(&path, &vars, vm_dir);
let full_path = resolve_path(&expanded, vm_dir);
let format = guess_disk_format(&full_path);
let interface = if line.contains("if=virtio") {
"virtio"
} else if line.contains("if=scsi") {
"scsi"
} else {
"ide"
};
disks.push(DiskConfig {
path: full_path,
format,
interface: interface.to_string(),
});
}
}
}
disks
}
fn extract_drive_file(line: &str) -> Option<String> {
if let Some(idx) = line.find("file=") {
let rest = &line[idx + 5..];
if let Some(inner) = rest.strip_prefix('"') {
let end = inner.find('"')?;
return Some(inner[..end].to_string());
}
let path: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && *c != ',' && *c != '\\')
.collect();
if !path.is_empty() {
return Some(path);
}
}
None
}
fn extract_path_from_arg(arg: &str) -> Option<String> {
let trimmed = arg.trim();
if let Some(inner) = trimmed.strip_prefix('"') {
let end = inner.find('"')?;
return Some(inner[..end].to_string());
}
if let Some(inner) = trimmed.strip_prefix('\'') {
let end = inner.find('\'')?;
return Some(inner[..end].to_string());
}
let path: String = trimmed
.chars()
.take_while(|c| !c.is_whitespace() && *c != '\\')
.collect();
if !path.is_empty() && !path.starts_with('-') {
Some(path)
} else {
None
}
}
fn resolve_path(path: &str, vm_dir: &Path) -> PathBuf {
let path = path.replace("$DIR", &vm_dir.to_string_lossy());
let path = path.replace("${DIR}", &vm_dir.to_string_lossy());
let path = path.replace("$(dirname $0)", &vm_dir.to_string_lossy());
let p = PathBuf::from(&path);
if p.is_absolute() {
p
} else {
vm_dir.join(p)
}
}
fn guess_disk_format(path: &Path) -> DiskFormat {
if path.exists() {
if let Some(format_str) = qemu_img::detect_disk_format(path) {
return match format_str.to_lowercase().as_str() {
"qcow2" => DiskFormat::Qcow2,
"raw" => DiskFormat::Raw,
"vmdk" => DiskFormat::Vmdk,
"vdi" => DiskFormat::Vdi,
other => DiskFormat::Other(other.to_string()),
};
}
}
path.extension()
.and_then(|ext| ext.to_str())
.map(DiskFormat::from_extension)
.unwrap_or(DiskFormat::Raw)
}
fn extract_network(content: &str) -> Option<NetworkConfig> {
let mut config = NetworkConfig::default();
let mut has_network = false;
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if line.contains("-device") {
if line.contains("virtio-net") {
config.model = "virtio-net".to_string();
has_network = true;
} else if line.contains("e1000") && line.contains("netdev=") {
config.model = "e1000".to_string();
has_network = true;
} else if line.contains("rtl8139") && line.contains("netdev=") {
config.model = "rtl8139".to_string();
has_network = true;
}
}
if line.contains("-net nic") || line.contains("-nic") {
has_network = true;
if line.contains("model=virtio") {
config.model = "virtio-net".to_string();
} else if line.contains("model=e1000") {
config.model = "e1000".to_string();
} else if line.contains("model=rtl8139") {
config.model = "rtl8139".to_string();
}
}
if line.contains("-netdev") {
has_network = true;
if line.contains("passt") {
config.backend = NetworkBackend::Passt;
config.user_net = false;
} else if line.contains("bridge") {
config.user_net = false;
if let Some(idx) = line.find("br=") {
let rest = &line[idx + 3..];
let bridge: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
config.backend = NetworkBackend::Bridge(bridge.clone());
config.bridge = Some(bridge);
} else {
config.backend = NetworkBackend::Bridge("qemubr0".to_string());
config.bridge = Some("qemubr0".to_string());
}
} else if line.contains("user") {
config.user_net = true;
config.backend = NetworkBackend::User;
config.port_forwards = extract_port_forwards(line);
}
}
if line.contains("-net user") {
has_network = true;
config.user_net = true;
config.backend = NetworkBackend::User;
config.port_forwards.extend(extract_port_forwards(line));
}
if line.contains("-net bridge") {
has_network = true;
config.user_net = false;
if let Some(idx) = line.find("br=") {
let rest = &line[idx + 3..];
let bridge: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
config.backend = NetworkBackend::Bridge(bridge.clone());
config.bridge = Some(bridge);
}
}
if (line.contains("-device") || line.contains("-netdev") || line.contains("-nic"))
&& line.contains("mac=")
{
if let Some(idx) = line.find("mac=") {
let rest = &line[idx + 4..];
let mac: String = rest
.chars()
.take_while(|c| c.is_ascii_hexdigit() || *c == ':')
.collect();
if crate::vm::mac::is_valid_mac(&mac) {
config.mac_address = Some(mac.to_lowercase());
has_network = true;
}
}
}
}
if has_network || content.contains("-net") || content.contains("-nic") {
Some(config)
} else {
None
}
}
fn extract_port_forwards(line: &str) -> Vec<PortForward> {
let mut forwards = Vec::new();
let mut search_from = 0;
while let Some(idx) = line[search_from..].find("hostfwd=") {
let start = search_from + idx + 8; let rest = &line[start..];
let segment: String = rest
.chars()
.take_while(|c| *c != ',' && !c.is_whitespace() && *c != '\\')
.collect();
if let Some(pf) = parse_hostfwd_segment(&segment) {
forwards.push(pf);
}
search_from = start + segment.len();
}
forwards
}
fn parse_hostfwd_segment(segment: &str) -> Option<PortForward> {
let parts: Vec<&str> = segment.splitn(2, '-').collect();
if parts.len() != 2 {
return None;
}
let host_part = parts[0]; let guest_part = parts[1];
let protocol = if host_part.starts_with("udp") {
PortProtocol::Udp
} else {
PortProtocol::Tcp
};
let host_port: u16 = host_part.rsplit(':').next()?.parse().ok()?;
let guest_port: u16 = guest_part.rsplit(':').next()?.parse().ok()?;
Some(PortForward {
protocol,
host_port,
guest_port,
})
}
fn extract_bios_path(content: &str, vm_dir: &Path) -> Option<PathBuf> {
let vars = extract_shell_variables(content, vm_dir);
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if let Some(idx) = trimmed.find("-bios ") {
let rest = &trimmed[idx + 6..];
let raw_path = if let Some(inner) = rest.strip_prefix('"') {
if let Some(end) = inner.find('"') {
inner[..end].to_string()
} else {
inner.to_string()
}
} else {
rest.chars()
.take_while(|c| !c.is_whitespace() && *c != '\\')
.collect()
};
let expanded = expand_variables(&raw_path, &vars, vm_dir);
let path = resolve_path(&expanded, vm_dir);
let path_str = path.to_string_lossy().to_lowercase();
if path_str.contains("ovmf") || path_str.contains("efi") || path_str.contains("uefi") {
continue;
}
return Some(path);
}
}
None
}
fn extract_extra_args(content: &str) -> Vec<String> {
let mut args = Vec::new();
for line in content.lines() {
if line.trim_start().starts_with('#') {
continue;
}
if let Some(idx) = line.find("-display ") {
let rest = &line[idx + 9..];
let backend: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-')
.collect();
if !backend.is_empty() {
args.push(format!("-display {}", backend));
break;
}
}
}
if content.contains("-usb") {
args.push("-usb".to_string());
}
if content.contains("-rtc base=localtime") {
args.push("-rtc base=localtime".to_string());
}
args
}
#[cfg(test)]
#[path = "tests/launch_parser.rs"]
mod tests;