use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub mod env;
mod loader;
pub use loader::ConfigLoader;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub boot: BootConfig,
#[serde(default)]
pub bootloader: BootloaderConfig,
#[serde(default)]
pub image: ImageConfig,
#[serde(default)]
pub runner: RunnerConfig,
#[serde(default)]
pub test: TestConfig,
#[serde(default)]
pub run: RunConfig,
#[serde(default)]
pub variables: HashMap<String, String>,
#[serde(default, rename = "extra-files")]
pub extra_files: HashMap<String, String>,
#[serde(default)]
pub verbose: bool,
}
impl Config {
pub fn from_toml_str(toml: &str) -> crate::core::Result<Self> {
toml::from_str(toml).map_err(|e| crate::core::Error::config(format!("failed to parse TOML config: {}", e)))
}
pub fn from_toml_file(path: impl AsRef<std::path::Path>) -> crate::core::Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| crate::core::Error::config(format!("failed to read config file: {}", e)))?;
Self::from_toml_str(&content)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootConfig {
#[serde(rename = "type")]
pub boot_type: BootType,
}
impl Default for BootConfig {
fn default() -> Self {
Self {
boot_type: BootType::Uefi,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BootType {
Bios,
Uefi,
Hybrid,
}
impl BootType {
pub fn needs_bios(self) -> bool {
matches!(self, BootType::Bios | BootType::Hybrid)
}
pub fn needs_uefi(self) -> bool {
matches!(self, BootType::Uefi | BootType::Hybrid)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BootloaderConfig {
pub kind: BootloaderKind,
#[serde(rename = "config-file")]
pub config_file: Option<PathBuf>,
#[serde(default)]
pub limine: LimineConfig,
#[serde(default)]
pub grub: GrubConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BootloaderKind {
Limine,
Grub,
#[default]
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LimineConfig {
pub version: String,
}
impl Default for LimineConfig {
fn default() -> Self {
Self {
version: "v8.x-binary".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GrubConfig {
#[serde(default)]
pub modules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageConfig {
pub format: ImageFormat,
pub output: Option<PathBuf>,
#[serde(default = "default_volume_label")]
pub volume_label: String,
}
impl Default for ImageConfig {
fn default() -> Self {
Self {
format: ImageFormat::Directory,
output: None,
volume_label: default_volume_label(),
}
}
}
fn default_volume_label() -> String {
"BOOT".to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ImageFormat {
Iso,
Fat,
#[default]
Directory,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RunnerConfig {
pub kind: RunnerKind,
#[serde(default)]
pub qemu: QemuConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RunnerKind {
#[default]
Qemu,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct QemuConfig {
#[serde(default = "default_qemu_binary")]
pub binary: String,
#[serde(default = "default_machine")]
pub machine: String,
#[serde(default = "default_memory")]
pub memory: u32,
#[serde(default = "default_cores")]
pub cores: u32,
#[serde(default = "default_true")]
pub kvm: bool,
#[serde(default)]
pub serial: SerialConfig,
#[serde(default)]
pub extra_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SerialConfig {
pub mode: SerialMode,
#[serde(default, rename = "separate-monitor")]
pub separate_monitor: Option<bool>,
}
impl Default for SerialConfig {
fn default() -> Self {
Self {
mode: SerialMode::default(),
separate_monitor: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SerialMode {
#[default]
#[serde(rename = "mon:stdio")]
MonStdio,
#[serde(rename = "stdio")]
Stdio,
#[serde(rename = "none")]
None,
}
fn default_qemu_binary() -> String {
"qemu-system-x86_64".to_string()
}
fn default_machine() -> String {
"q35".to_string()
}
fn default_memory() -> u32 {
1024
}
fn default_cores() -> u32 {
1
}
impl Default for QemuConfig {
fn default() -> Self {
Self {
binary: "qemu-system-x86_64".to_string(),
machine: "q35".to_string(),
memory: 1024,
cores: 1,
kvm: true,
serial: SerialConfig::default(),
extra_args: Vec::new(),
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestConfig {
#[serde(rename = "success-exit-code")]
pub success_exit_code: Option<i32>,
#[serde(default, rename = "extra-args")]
pub extra_args: Vec<String>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RunConfig {
#[serde(default, rename = "extra-args")]
pub extra_args: Vec<String>,
#[serde(default)]
pub gui: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default_values() {
let config = Config::default();
assert_eq!(config.boot.boot_type, BootType::Uefi);
assert_eq!(config.bootloader.kind, BootloaderKind::None);
assert!(config.bootloader.config_file.is_none());
assert_eq!(config.image.format, ImageFormat::Directory);
assert!(config.image.output.is_none());
assert_eq!(config.image.volume_label, "BOOT");
assert_eq!(config.runner.kind, RunnerKind::Qemu);
assert!(config.test.success_exit_code.is_none());
assert!(config.test.extra_args.is_empty());
assert!(config.test.timeout.is_none());
assert!(!config.run.gui);
assert!(config.run.extra_args.is_empty());
assert!(config.variables.is_empty());
assert!(config.extra_files.is_empty());
assert!(!config.verbose);
}
#[test]
fn test_config_deserialize_minimal() {
let toml_str = r#"
[boot]
type = "uefi"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.boot.boot_type, BootType::Uefi);
assert_eq!(config.bootloader.kind, BootloaderKind::None);
assert_eq!(config.image.format, ImageFormat::Directory);
}
#[test]
fn test_config_deserialize_full() {
let toml_str = r#"
verbose = true
[boot]
type = "hybrid"
[bootloader]
kind = "limine"
config-file = "limine.conf"
[bootloader.limine]
version = "v8.4.0-binary"
[image]
format = "iso"
output = "my-os.iso"
volume_label = "MYOS"
[runner]
kind = "qemu"
[runner.qemu]
binary = "qemu-system-x86_64"
memory = 2048
cores = 2
kvm = false
[test]
success-exit-code = 33
timeout = 30
extra-args = ["-device", "isa-debug-exit"]
[run]
gui = true
extra-args = ["-serial", "stdio"]
[variables]
TIMEOUT = "5"
[extra-files]
"boot/extra.bin" = "extra.bin"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.boot.boot_type, BootType::Hybrid);
assert_eq!(config.bootloader.kind, BootloaderKind::Limine);
assert_eq!(
config.bootloader.config_file,
Some(PathBuf::from("limine.conf"))
);
assert_eq!(config.bootloader.limine.version, "v8.4.0-binary");
assert_eq!(config.image.format, ImageFormat::Iso);
assert_eq!(config.image.output, Some(PathBuf::from("my-os.iso")));
assert_eq!(config.image.volume_label, "MYOS");
assert_eq!(config.runner.qemu.memory, 2048);
assert_eq!(config.runner.qemu.cores, 2);
assert!(!config.runner.qemu.kvm);
assert_eq!(config.test.success_exit_code, Some(33));
assert_eq!(config.test.timeout, Some(30));
assert!(config.run.gui);
assert_eq!(config.variables.get("TIMEOUT").unwrap(), "5");
assert_eq!(config.extra_files.get("boot/extra.bin").unwrap(), "extra.bin");
assert!(config.verbose);
}
#[test]
fn test_config_deserialize_bios_boot_type() {
let toml_str = r#"
[boot]
type = "bios"
[bootloader]
kind = "grub"
[image]
format = "fat"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.boot.boot_type, BootType::Bios);
assert_eq!(config.bootloader.kind, BootloaderKind::Grub);
assert_eq!(config.image.format, ImageFormat::Fat);
}
#[test]
fn test_config_deserialize_invalid_boot_type() {
let toml_str = r#"
[boot]
type = "invalid"
"#;
let result: std::result::Result<Config, _> = toml::from_str(toml_str);
assert!(result.is_err());
}
#[test]
fn test_boot_type_needs_bios() {
assert!(BootType::Bios.needs_bios());
assert!(!BootType::Uefi.needs_bios());
assert!(BootType::Hybrid.needs_bios());
}
#[test]
fn test_boot_type_needs_uefi() {
assert!(!BootType::Bios.needs_uefi());
assert!(BootType::Uefi.needs_uefi());
assert!(BootType::Hybrid.needs_uefi());
}
#[test]
fn test_qemu_config_defaults() {
let qemu = QemuConfig::default();
assert_eq!(qemu.binary, "qemu-system-x86_64");
assert_eq!(qemu.machine, "q35");
assert_eq!(qemu.memory, 1024);
assert_eq!(qemu.cores, 1);
assert!(qemu.kvm);
assert_eq!(qemu.serial.mode, SerialMode::MonStdio);
assert_eq!(qemu.serial.separate_monitor, None);
assert!(qemu.extra_args.is_empty());
}
#[test]
fn test_serial_config_defaults() {
let serial = SerialConfig::default();
assert_eq!(serial.mode, SerialMode::MonStdio);
assert_eq!(serial.separate_monitor, None);
}
#[test]
fn test_serial_config_deserialize_stdio() {
let toml_str = r#"
[runner]
kind = "qemu"
[runner.qemu.serial]
mode = "stdio"
separate-monitor = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.runner.qemu.serial.mode, SerialMode::Stdio);
assert_eq!(config.runner.qemu.serial.separate_monitor, Some(true));
}
#[test]
fn test_serial_config_deserialize_none() {
let toml_str = r#"
[runner]
kind = "qemu"
[runner.qemu.serial]
mode = "none"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.runner.qemu.serial.mode, SerialMode::None);
assert_eq!(config.runner.qemu.serial.separate_monitor, None);
}
#[test]
fn test_serial_config_deserialize_mon_stdio() {
let toml_str = r#"
[runner]
kind = "qemu"
[runner.qemu.serial]
mode = "mon:stdio"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.runner.qemu.serial.mode, SerialMode::MonStdio);
}
#[test]
fn test_serial_config_omitted_uses_defaults() {
let toml_str = r#"
[runner]
kind = "qemu"
[runner.qemu]
memory = 2048
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.runner.qemu.serial.mode, SerialMode::MonStdio);
assert_eq!(config.runner.qemu.serial.separate_monitor, None);
}
#[test]
fn test_limine_config_default_version() {
let limine = LimineConfig::default();
assert_eq!(limine.version, "v8.x-binary");
}
#[test]
fn test_extra_files_deserialize_empty() {
let toml_str = r#"
[boot]
type = "uefi"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.extra_files.is_empty());
}
#[test]
fn test_extra_files_deserialize_nested_paths() {
let toml_str = r#"
[extra-files]
"boot/initramfs.cpio" = "build/initramfs.cpio"
"boot/data/config.txt" = "data/config.txt"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.extra_files.len(), 2);
assert_eq!(
config.extra_files.get("boot/initramfs.cpio").unwrap(),
"build/initramfs.cpio"
);
assert_eq!(
config.extra_files.get("boot/data/config.txt").unwrap(),
"data/config.txt"
);
}
}