Skip to main content

cargo_image_runner/config/
mod.rs

1//! Configuration types and loading from `[package.metadata.image-runner]` in Cargo.toml.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7pub mod env;
8mod loader;
9pub use loader::ConfigLoader;
10
11/// Complete configuration for image runner.
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct Config {
14    /// Boot type configuration.
15    #[serde(default)]
16    pub boot: BootConfig,
17
18    /// Bootloader configuration.
19    #[serde(default)]
20    pub bootloader: BootloaderConfig,
21
22    /// Image format configuration.
23    #[serde(default)]
24    pub image: ImageConfig,
25
26    /// Runner configuration.
27    #[serde(default)]
28    pub runner: RunnerConfig,
29
30    /// Test-specific configuration.
31    #[serde(default)]
32    pub test: TestConfig,
33
34    /// Run-specific configuration (non-test).
35    #[serde(default)]
36    pub run: RunConfig,
37
38    /// Template variables for substitution.
39    #[serde(default)]
40    pub variables: HashMap<String, String>,
41
42    /// Extra files to include in the image (dest path → source path).
43    #[serde(default, rename = "extra-files")]
44    pub extra_files: HashMap<String, String>,
45
46    /// Enable verbose output (show build progress messages).
47    #[serde(default)]
48    pub verbose: bool,
49}
50
51impl Config {
52    /// Parse a `Config` directly from a TOML string.
53    ///
54    /// This bypasses `ConfigLoader` entirely — no profiles, no env var overrides.
55    /// Useful for standalone/library consumers who don't use `cargo_metadata`.
56    pub fn from_toml_str(toml: &str) -> crate::core::Result<Self> {
57        toml::from_str(toml).map_err(|e| crate::core::Error::config(format!("failed to parse TOML config: {}", e)))
58    }
59
60    /// Parse a `Config` from a TOML file on disk.
61    ///
62    /// This bypasses `ConfigLoader` entirely — no profiles, no env var overrides.
63    /// Useful for standalone/library consumers who don't use `cargo_metadata`.
64    pub fn from_toml_file(path: impl AsRef<std::path::Path>) -> crate::core::Result<Self> {
65        let content = std::fs::read_to_string(path.as_ref())
66            .map_err(|e| crate::core::Error::config(format!("failed to read config file: {}", e)))?;
67        Self::from_toml_str(&content)
68    }
69}
70
71/// Boot type configuration.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct BootConfig {
74    /// Boot type: BIOS, UEFI, or Hybrid.
75    #[serde(rename = "type")]
76    pub boot_type: BootType,
77}
78
79impl Default for BootConfig {
80    fn default() -> Self {
81        Self {
82            boot_type: BootType::Uefi,
83        }
84    }
85}
86
87/// Boot type enumeration.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum BootType {
91    /// BIOS boot only.
92    Bios,
93    /// UEFI boot only.
94    Uefi,
95    /// Support both BIOS and UEFI.
96    Hybrid,
97}
98
99impl BootType {
100    /// Check if BIOS boot is required.
101    pub fn needs_bios(self) -> bool {
102        matches!(self, BootType::Bios | BootType::Hybrid)
103    }
104
105    /// Check if UEFI boot is required.
106    pub fn needs_uefi(self) -> bool {
107        matches!(self, BootType::Uefi | BootType::Hybrid)
108    }
109}
110
111/// Bootloader configuration.
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct BootloaderConfig {
114    /// Bootloader type.
115    pub kind: BootloaderKind,
116
117    /// Path to bootloader configuration file.
118    #[serde(rename = "config-file")]
119    pub config_file: Option<PathBuf>,
120
121    /// Limine-specific configuration.
122    #[serde(default)]
123    pub limine: LimineConfig,
124
125    /// GRUB-specific configuration.
126    #[serde(default)]
127    pub grub: GrubConfig,
128}
129
130/// Bootloader type enumeration.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
132#[serde(rename_all = "lowercase")]
133pub enum BootloaderKind {
134    /// Limine bootloader.
135    Limine,
136    /// GRUB bootloader.
137    Grub,
138    /// No bootloader (direct boot).
139    #[default]
140    None,
141}
142
143/// Limine bootloader configuration.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct LimineConfig {
146    /// Limine version to use.
147    pub version: String,
148}
149
150impl Default for LimineConfig {
151    fn default() -> Self {
152        Self {
153            version: "v8.x-binary".to_string(),
154        }
155    }
156}
157
158/// GRUB bootloader configuration.
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct GrubConfig {
161    /// GRUB modules to include.
162    #[serde(default)]
163    pub modules: Vec<String>,
164}
165
166/// Image format configuration.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ImageConfig {
169    /// Image format type.
170    pub format: ImageFormat,
171
172    /// Output path for the image.
173    pub output: Option<PathBuf>,
174
175    /// Volume label (for ISO/FAT).
176    #[serde(default = "default_volume_label")]
177    pub volume_label: String,
178}
179
180impl Default for ImageConfig {
181    fn default() -> Self {
182        Self {
183            format: ImageFormat::Directory,
184            output: None,
185            volume_label: default_volume_label(),
186        }
187    }
188}
189
190fn default_volume_label() -> String {
191    "BOOT".to_string()
192}
193
194/// Image format enumeration.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "lowercase")]
197pub enum ImageFormat {
198    /// ISO 9660 image.
199    Iso,
200    /// FAT filesystem image.
201    Fat,
202    /// Directory (for QEMU fat:rw:).
203    #[default]
204    Directory,
205}
206
207/// Runner configuration.
208#[derive(Debug, Clone, Serialize, Deserialize, Default)]
209pub struct RunnerConfig {
210    /// Runner type.
211    pub kind: RunnerKind,
212
213    /// QEMU-specific configuration.
214    #[serde(default)]
215    pub qemu: QemuConfig,
216}
217
218/// Runner type enumeration.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
220#[serde(rename_all = "lowercase")]
221pub enum RunnerKind {
222    /// QEMU emulator.
223    #[default]
224    Qemu,
225}
226
227/// QEMU runner configuration.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(default)]
230pub struct QemuConfig {
231    /// QEMU binary to use.
232    #[serde(default = "default_qemu_binary")]
233    pub binary: String,
234
235    /// Machine type.
236    #[serde(default = "default_machine")]
237    pub machine: String,
238
239    /// Memory size in MB.
240    #[serde(default = "default_memory")]
241    pub memory: u32,
242
243    /// Number of CPU cores.
244    #[serde(default = "default_cores")]
245    pub cores: u32,
246
247    /// Enable KVM acceleration.
248    #[serde(default = "default_true")]
249    pub kvm: bool,
250
251    /// Serial port configuration.
252    #[serde(default)]
253    pub serial: SerialConfig,
254
255    /// Additional QEMU arguments.
256    #[serde(default)]
257    pub extra_args: Vec<String>,
258}
259
260/// Serial port configuration for QEMU.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(default)]
263pub struct SerialConfig {
264    /// Serial mode: `"mon:stdio"` (default), `"stdio"`, `"none"`.
265    pub mode: SerialMode,
266    /// Separate QEMU monitor from serial port.
267    /// When `None`, the runner decides automatically.
268    #[serde(default, rename = "separate-monitor")]
269    pub separate_monitor: Option<bool>,
270}
271
272impl Default for SerialConfig {
273    fn default() -> Self {
274        Self {
275            mode: SerialMode::default(),
276            separate_monitor: None,
277        }
278    }
279}
280
281/// Serial mode for QEMU's `-serial` flag.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
283pub enum SerialMode {
284    /// `-serial mon:stdio` — serial + monitor multiplexed on stdio (default).
285    #[default]
286    #[serde(rename = "mon:stdio")]
287    MonStdio,
288    /// `-serial stdio` — serial only on stdio.
289    #[serde(rename = "stdio")]
290    Stdio,
291    /// `-serial none` — no serial output.
292    #[serde(rename = "none")]
293    None,
294}
295
296fn default_qemu_binary() -> String {
297    "qemu-system-x86_64".to_string()
298}
299
300fn default_machine() -> String {
301    "q35".to_string()
302}
303
304fn default_memory() -> u32 {
305    1024
306}
307
308fn default_cores() -> u32 {
309    1
310}
311
312impl Default for QemuConfig {
313    fn default() -> Self {
314        Self {
315            binary: "qemu-system-x86_64".to_string(),
316            machine: "q35".to_string(),
317            memory: 1024,
318            cores: 1,
319            kvm: true,
320            serial: SerialConfig::default(),
321            extra_args: Vec::new(),
322        }
323    }
324}
325
326fn default_true() -> bool {
327    true
328}
329
330/// Test-specific configuration.
331#[derive(Debug, Clone, Serialize, Deserialize, Default)]
332pub struct TestConfig {
333    /// Exit code that indicates test success.
334    #[serde(rename = "success-exit-code")]
335    pub success_exit_code: Option<i32>,
336
337    /// Additional arguments for test runs.
338    #[serde(default, rename = "extra-args")]
339    pub extra_args: Vec<String>,
340
341    /// Timeout for tests in seconds.
342    pub timeout: Option<u64>,
343}
344
345/// Run-specific configuration (non-test).
346#[derive(Debug, Clone, Serialize, Deserialize, Default)]
347pub struct RunConfig {
348    /// Additional arguments for normal runs.
349    #[serde(default, rename = "extra-args")]
350    pub extra_args: Vec<String>,
351
352    /// Whether to use GUI display.
353    #[serde(default)]
354    pub gui: bool,
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_config_default_values() {
363        let config = Config::default();
364        assert_eq!(config.boot.boot_type, BootType::Uefi);
365        assert_eq!(config.bootloader.kind, BootloaderKind::None);
366        assert!(config.bootloader.config_file.is_none());
367        assert_eq!(config.image.format, ImageFormat::Directory);
368        assert!(config.image.output.is_none());
369        assert_eq!(config.image.volume_label, "BOOT");
370        assert_eq!(config.runner.kind, RunnerKind::Qemu);
371        assert!(config.test.success_exit_code.is_none());
372        assert!(config.test.extra_args.is_empty());
373        assert!(config.test.timeout.is_none());
374        assert!(!config.run.gui);
375        assert!(config.run.extra_args.is_empty());
376        assert!(config.variables.is_empty());
377        assert!(config.extra_files.is_empty());
378        assert!(!config.verbose);
379    }
380
381    #[test]
382    fn test_config_deserialize_minimal() {
383        let toml_str = r#"
384        [boot]
385        type = "uefi"
386        "#;
387        let config: Config = toml::from_str(toml_str).unwrap();
388        assert_eq!(config.boot.boot_type, BootType::Uefi);
389        assert_eq!(config.bootloader.kind, BootloaderKind::None);
390        assert_eq!(config.image.format, ImageFormat::Directory);
391    }
392
393    #[test]
394    fn test_config_deserialize_full() {
395        let toml_str = r#"
396        verbose = true
397
398        [boot]
399        type = "hybrid"
400
401        [bootloader]
402        kind = "limine"
403        config-file = "limine.conf"
404
405        [bootloader.limine]
406        version = "v8.4.0-binary"
407
408        [image]
409        format = "iso"
410        output = "my-os.iso"
411        volume_label = "MYOS"
412
413        [runner]
414        kind = "qemu"
415
416        [runner.qemu]
417        binary = "qemu-system-x86_64"
418        memory = 2048
419        cores = 2
420        kvm = false
421
422        [test]
423        success-exit-code = 33
424        timeout = 30
425        extra-args = ["-device", "isa-debug-exit"]
426
427        [run]
428        gui = true
429        extra-args = ["-serial", "stdio"]
430
431        [variables]
432        TIMEOUT = "5"
433
434        [extra-files]
435        "boot/extra.bin" = "extra.bin"
436        "#;
437        let config: Config = toml::from_str(toml_str).unwrap();
438        assert_eq!(config.boot.boot_type, BootType::Hybrid);
439        assert_eq!(config.bootloader.kind, BootloaderKind::Limine);
440        assert_eq!(
441            config.bootloader.config_file,
442            Some(PathBuf::from("limine.conf"))
443        );
444        assert_eq!(config.bootloader.limine.version, "v8.4.0-binary");
445        assert_eq!(config.image.format, ImageFormat::Iso);
446        assert_eq!(config.image.output, Some(PathBuf::from("my-os.iso")));
447        assert_eq!(config.image.volume_label, "MYOS");
448        assert_eq!(config.runner.qemu.memory, 2048);
449        assert_eq!(config.runner.qemu.cores, 2);
450        assert!(!config.runner.qemu.kvm);
451        assert_eq!(config.test.success_exit_code, Some(33));
452        assert_eq!(config.test.timeout, Some(30));
453        assert!(config.run.gui);
454        assert_eq!(config.variables.get("TIMEOUT").unwrap(), "5");
455        assert_eq!(config.extra_files.get("boot/extra.bin").unwrap(), "extra.bin");
456        assert!(config.verbose);
457    }
458
459    #[test]
460    fn test_config_deserialize_bios_boot_type() {
461        let toml_str = r#"
462        [boot]
463        type = "bios"
464
465        [bootloader]
466        kind = "grub"
467
468        [image]
469        format = "fat"
470        "#;
471        let config: Config = toml::from_str(toml_str).unwrap();
472        assert_eq!(config.boot.boot_type, BootType::Bios);
473        assert_eq!(config.bootloader.kind, BootloaderKind::Grub);
474        assert_eq!(config.image.format, ImageFormat::Fat);
475    }
476
477    #[test]
478    fn test_config_deserialize_invalid_boot_type() {
479        let toml_str = r#"
480        [boot]
481        type = "invalid"
482        "#;
483        let result: std::result::Result<Config, _> = toml::from_str(toml_str);
484        assert!(result.is_err());
485    }
486
487    #[test]
488    fn test_boot_type_needs_bios() {
489        assert!(BootType::Bios.needs_bios());
490        assert!(!BootType::Uefi.needs_bios());
491        assert!(BootType::Hybrid.needs_bios());
492    }
493
494    #[test]
495    fn test_boot_type_needs_uefi() {
496        assert!(!BootType::Bios.needs_uefi());
497        assert!(BootType::Uefi.needs_uefi());
498        assert!(BootType::Hybrid.needs_uefi());
499    }
500
501    #[test]
502    fn test_qemu_config_defaults() {
503        let qemu = QemuConfig::default();
504        assert_eq!(qemu.binary, "qemu-system-x86_64");
505        assert_eq!(qemu.machine, "q35");
506        assert_eq!(qemu.memory, 1024);
507        assert_eq!(qemu.cores, 1);
508        assert!(qemu.kvm);
509        assert_eq!(qemu.serial.mode, SerialMode::MonStdio);
510        assert_eq!(qemu.serial.separate_monitor, None);
511        assert!(qemu.extra_args.is_empty());
512    }
513
514    #[test]
515    fn test_serial_config_defaults() {
516        let serial = SerialConfig::default();
517        assert_eq!(serial.mode, SerialMode::MonStdio);
518        assert_eq!(serial.separate_monitor, None);
519    }
520
521    #[test]
522    fn test_serial_config_deserialize_stdio() {
523        let toml_str = r#"
524        [runner]
525        kind = "qemu"
526
527        [runner.qemu.serial]
528        mode = "stdio"
529        separate-monitor = true
530        "#;
531        let config: Config = toml::from_str(toml_str).unwrap();
532        assert_eq!(config.runner.qemu.serial.mode, SerialMode::Stdio);
533        assert_eq!(config.runner.qemu.serial.separate_monitor, Some(true));
534    }
535
536    #[test]
537    fn test_serial_config_deserialize_none() {
538        let toml_str = r#"
539        [runner]
540        kind = "qemu"
541
542        [runner.qemu.serial]
543        mode = "none"
544        "#;
545        let config: Config = toml::from_str(toml_str).unwrap();
546        assert_eq!(config.runner.qemu.serial.mode, SerialMode::None);
547        assert_eq!(config.runner.qemu.serial.separate_monitor, None);
548    }
549
550    #[test]
551    fn test_serial_config_deserialize_mon_stdio() {
552        let toml_str = r#"
553        [runner]
554        kind = "qemu"
555
556        [runner.qemu.serial]
557        mode = "mon:stdio"
558        "#;
559        let config: Config = toml::from_str(toml_str).unwrap();
560        assert_eq!(config.runner.qemu.serial.mode, SerialMode::MonStdio);
561    }
562
563    #[test]
564    fn test_serial_config_omitted_uses_defaults() {
565        let toml_str = r#"
566        [runner]
567        kind = "qemu"
568
569        [runner.qemu]
570        memory = 2048
571        "#;
572        let config: Config = toml::from_str(toml_str).unwrap();
573        assert_eq!(config.runner.qemu.serial.mode, SerialMode::MonStdio);
574        assert_eq!(config.runner.qemu.serial.separate_monitor, None);
575    }
576
577    #[test]
578    fn test_limine_config_default_version() {
579        let limine = LimineConfig::default();
580        assert_eq!(limine.version, "v8.x-binary");
581    }
582
583    #[test]
584    fn test_extra_files_deserialize_empty() {
585        let toml_str = r#"
586        [boot]
587        type = "uefi"
588        "#;
589        let config: Config = toml::from_str(toml_str).unwrap();
590        assert!(config.extra_files.is_empty());
591    }
592
593    #[test]
594    fn test_extra_files_deserialize_nested_paths() {
595        let toml_str = r#"
596        [extra-files]
597        "boot/initramfs.cpio" = "build/initramfs.cpio"
598        "boot/data/config.txt" = "data/config.txt"
599        "#;
600        let config: Config = toml::from_str(toml_str).unwrap();
601        assert_eq!(config.extra_files.len(), 2);
602        assert_eq!(
603            config.extra_files.get("boot/initramfs.cpio").unwrap(),
604            "build/initramfs.cpio"
605        );
606        assert_eq!(
607            config.extra_files.get("boot/data/config.txt").unwrap(),
608            "data/config.txt"
609        );
610    }
611}