Skip to main content

cargo_image_runner/core/
builder.rs

1use crate::bootloader::Bootloader;
2use crate::config::{BootloaderKind, Config, ImageFormat, RunnerKind};
3#[cfg(feature = "cargo-metadata")]
4use crate::config::ConfigLoader;
5use crate::core::context::Context;
6use crate::core::error::{Error, Result};
7use crate::image::ImageBuilder;
8use crate::runner::io::IoHandler;
9use crate::runner::{RunResult, Runner};
10use std::path::PathBuf;
11
12/// Builder for creating and running bootable images.
13pub struct ImageRunnerBuilder {
14    config: Option<Config>,
15    workspace_root: Option<PathBuf>,
16    executable: Option<PathBuf>,
17    bootloader: Option<Box<dyn Bootloader>>,
18    image_builder: Option<Box<dyn ImageBuilder>>,
19    runner: Option<Box<dyn Runner>>,
20    cli_extra_args: Vec<String>,
21    io_handler: Option<Box<dyn IoHandler>>,
22}
23
24impl ImageRunnerBuilder {
25    /// Create a new builder with default settings.
26    pub fn new() -> Self {
27        Self {
28            config: None,
29            workspace_root: None,
30            executable: None,
31            bootloader: None,
32            image_builder: None,
33            runner: None,
34            cli_extra_args: Vec::new(),
35            io_handler: None,
36        }
37    }
38
39    /// Set the configuration directly.
40    pub fn with_config(mut self, config: Config) -> Self {
41        self.config = Some(config);
42        self
43    }
44
45    /// Load configuration from Cargo.toml metadata.
46    #[cfg(feature = "cargo-metadata")]
47    pub fn from_cargo_metadata(mut self) -> Result<Self> {
48        let (config, workspace_root) = ConfigLoader::new().load()?;
49        self.config = Some(config);
50        self.workspace_root = Some(workspace_root);
51        Ok(self)
52    }
53
54    /// Load configuration from a standalone TOML file.
55    #[cfg(feature = "cargo-metadata")]
56    pub fn from_config_file(mut self, path: impl Into<PathBuf>) -> Result<Self> {
57        let (config, workspace_root) = ConfigLoader::new().config_file(path).load()?;
58        self.config = Some(config);
59        self.workspace_root = Some(workspace_root);
60        Ok(self)
61    }
62
63    /// Set the executable path.
64    pub fn executable(mut self, path: impl Into<PathBuf>) -> Self {
65        self.executable = Some(path.into());
66        self
67    }
68
69    /// Set the workspace root.
70    pub fn workspace_root(mut self, path: impl Into<PathBuf>) -> Self {
71        self.workspace_root = Some(path.into());
72        self
73    }
74
75    /// Set extra QEMU arguments from CLI passthrough (`-- args`).
76    pub fn extra_args(mut self, args: Vec<String>) -> Self {
77        self.cli_extra_args = args;
78        self
79    }
80
81    // --- Bootloader Configuration ---
82
83    /// Set a custom bootloader implementation.
84    pub fn bootloader<B: Bootloader + 'static>(mut self, bootloader: B) -> Self {
85        self.bootloader = Some(Box::new(bootloader));
86        self
87    }
88
89    /// Use Limine bootloader.
90    #[cfg(feature = "limine")]
91    pub fn limine(mut self) -> Self {
92        self.bootloader = Some(Box::new(crate::bootloader::limine::LimineBootloader::new()));
93        self
94    }
95
96    /// Use GRUB bootloader.
97    pub fn grub(mut self) -> Self {
98        self.bootloader = Some(Box::new(crate::bootloader::grub::GrubBootloader::new()));
99        self
100    }
101
102    /// Use no bootloader (direct boot).
103    pub fn no_bootloader(mut self) -> Self {
104        self.bootloader = Some(Box::new(crate::bootloader::none::NoneBootloader::new()));
105        self
106    }
107
108    // --- Image Format Configuration ---
109
110    /// Set a custom image builder implementation.
111    pub fn image_builder<I: ImageBuilder + 'static>(mut self, builder: I) -> Self {
112        self.image_builder = Some(Box::new(builder));
113        self
114    }
115
116    /// Build an ISO image.
117    #[cfg(feature = "iso")]
118    pub fn iso_image(mut self) -> Self {
119        self.image_builder = Some(Box::new(crate::image::iso::IsoImageBuilder::new()));
120        self
121    }
122
123    /// Build a FAT filesystem image.
124    #[cfg(feature = "fat")]
125    pub fn fat_image(mut self) -> Self {
126        self.image_builder = Some(Box::new(crate::image::fat::FatImageBuilder::new()));
127        self
128    }
129
130    /// Output to a directory (for QEMU fat:rw:).
131    pub fn directory_output(mut self) -> Self {
132        self.image_builder = Some(Box::new(crate::image::directory::DirectoryBuilder::new()));
133        self
134    }
135
136    // --- Runner Configuration ---
137
138    /// Set a custom runner implementation.
139    pub fn runner<R: Runner + 'static>(mut self, runner: R) -> Self {
140        self.runner = Some(Box::new(runner));
141        self
142    }
143
144    /// Use QEMU runner.
145    #[cfg(feature = "qemu")]
146    pub fn qemu(mut self) -> Self {
147        self.runner = Some(Box::new(crate::runner::qemu::QemuRunner::new()));
148        self
149    }
150
151    // --- I/O Handler Configuration ---
152
153    /// Set an I/O handler for serial capture/streaming.
154    ///
155    /// When set, the runner will pipe QEMU's serial output through the handler,
156    /// enabling capture, pattern matching, and reactive input.
157    pub fn io_handler<H: IoHandler + 'static>(mut self, handler: H) -> Self {
158        self.io_handler = Some(Box::new(handler));
159        self
160    }
161
162    // --- Build and Execute ---
163
164    /// Build the image runner.
165    pub fn build(self) -> Result<ImageRunner> {
166        let config = self.config.ok_or_else(|| Error::config("no configuration provided"))?;
167
168        let workspace_root = self.workspace_root.ok_or_else(|| {
169            Error::config("workspace root not set (call from_cargo_metadata or workspace_root)")
170        })?;
171
172        let executable = self.executable.ok_or_else(|| {
173            Error::config("executable not set (call executable or get from CLI args)")
174        })?;
175
176        // Create bootloader from config if not explicitly set
177        let bootloader = if let Some(bl) = self.bootloader {
178            bl
179        } else {
180            create_bootloader_from_config(&config)?
181        };
182
183        // Create image builder from config if not explicitly set
184        let image_builder = if let Some(ib) = self.image_builder {
185            ib
186        } else {
187            create_image_builder_from_config(&config)?
188        };
189
190        // Create runner from config if not explicitly set
191        let runner = if let Some(r) = self.runner {
192            r
193        } else {
194            create_runner_from_config(&config)?
195        };
196
197        Ok(ImageRunner {
198            config,
199            workspace_root,
200            executable,
201            bootloader,
202            image_builder,
203            runner,
204            cli_extra_args: self.cli_extra_args,
205            io_handler: self.io_handler,
206        })
207    }
208
209    /// Build and immediately run.
210    pub fn run(self) -> Result<()> {
211        let runner = self.build()?;
212        runner.run()
213    }
214
215    /// Build and immediately run, returning the full [`RunResult`].
216    pub fn run_with_result(self) -> Result<RunResult> {
217        let runner = self.build()?;
218        runner.run_with_result()
219    }
220}
221
222impl Default for ImageRunnerBuilder {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228/// Image runner that orchestrates the build and run process.
229pub struct ImageRunner {
230    config: Config,
231    workspace_root: PathBuf,
232    executable: PathBuf,
233    bootloader: Box<dyn Bootloader>,
234    image_builder: Box<dyn ImageBuilder>,
235    runner: Box<dyn Runner>,
236    cli_extra_args: Vec<String>,
237    io_handler: Option<Box<dyn IoHandler>>,
238}
239
240impl ImageRunner {
241    /// Build the image without running it.
242    ///
243    /// Returns the path to the built image.
244    pub fn build_image(&self) -> Result<PathBuf> {
245        // Create context
246        let mut ctx = Context::new(
247            self.config.clone(),
248            self.workspace_root.clone(),
249            self.executable.clone(),
250        )?;
251        ctx.cli_extra_args = self.cli_extra_args.clone();
252        ctx.env_extra_args = crate::config::env::get_extra_qemu_args();
253
254        // Update ARGS template variable with CLI args
255        ctx.template_vars.insert(
256            "ARGS".to_string(),
257            ctx.cli_extra_args.join(" "),
258        );
259
260        // Validate all components
261        self.bootloader.validate_config(&ctx)?;
262        self.image_builder.validate_boot_type(&ctx)?;
263
264        // Prepare bootloader files
265        if ctx.config.verbose {
266            println!("Preparing bootloader: {}", self.bootloader.name());
267        }
268        let bootloader_files = self.bootloader.prepare(&ctx)?;
269
270        // Get config files and process templates
271        let config_files = self.bootloader.config_files(&ctx)?;
272        let mut all_files = Vec::new();
273
274        // Add bootloader files
275        all_files.extend(bootloader_files.bios_files);
276        all_files.extend(bootloader_files.uefi_files);
277        all_files.extend(bootloader_files.system_files);
278
279        // Process config files with templates
280        for config_file in config_files {
281            if config_file.needs_template_processing {
282                // Read, process, and write template
283                let content = std::fs::read_to_string(&config_file.source)?;
284                let processed = self.bootloader.process_templates(&content, &ctx.template_vars)?;
285
286                // Write to temporary file
287                let temp_path = ctx.output_dir.join("processed_config");
288                std::fs::create_dir_all(&temp_path)?;
289                let processed_file = temp_path.join(
290                    config_file
291                        .source
292                        .file_name()
293                        .ok_or_else(|| Error::config("invalid config file path"))?,
294                );
295                std::fs::write(&processed_file, processed)?;
296
297                all_files.push(crate::bootloader::FileEntry::new(
298                    processed_file,
299                    config_file.dest,
300                ));
301            } else {
302                all_files.push(crate::bootloader::FileEntry::new(
303                    config_file.source,
304                    config_file.dest,
305                ));
306            }
307        }
308
309        // Add extra files from config
310        all_files.extend(collect_extra_files(&ctx)?);
311
312        // Build image
313        if ctx.config.verbose {
314            println!("Building image: {}", self.image_builder.name());
315        }
316        let image_path = self.image_builder.build(&ctx, &all_files)?;
317
318        Ok(image_path)
319    }
320
321    /// Run the full pipeline: prepare bootloader, build image, execute.
322    ///
323    /// Returns `Ok(())` on success or an error on failure.
324    /// If an I/O handler was set, it will be used during execution.
325    pub fn run(self) -> Result<()> {
326        let result = self.run_with_result()?;
327
328        // The run_with_result already checks exit codes, so if we get here
329        // it means the run was considered successful or we need to check.
330        // However, run_with_result returns the raw result, so we check here.
331        if result.timed_out {
332            return Err(Error::runner("test timed out"));
333        }
334
335        if !result.success {
336            return Err(Error::runner(format!(
337                "Execution failed with exit code: {}",
338                result.exit_code
339            )));
340        }
341
342        Ok(())
343    }
344
345    /// Run the full pipeline and return the detailed [`RunResult`].
346    ///
347    /// Unlike [`run()`](Self::run), this does not error on non-zero exit codes.
348    /// The caller can inspect the result to determine success/failure.
349    /// If an I/O handler was set, captured output will be available in the result.
350    pub fn run_with_result(mut self) -> Result<RunResult> {
351        // Create context
352        let mut ctx = Context::new(self.config, self.workspace_root, self.executable)?;
353        ctx.cli_extra_args = self.cli_extra_args;
354        ctx.env_extra_args = crate::config::env::get_extra_qemu_args();
355
356        // Update ARGS template variable with CLI args
357        ctx.template_vars.insert(
358            "ARGS".to_string(),
359            ctx.cli_extra_args.join(" "),
360        );
361
362        // Validate all components
363        self.bootloader.validate_config(&ctx)?;
364        self.image_builder.validate_boot_type(&ctx)?;
365        self.runner.validate(&ctx)?;
366
367        // Prepare bootloader files
368        if ctx.config.verbose {
369            println!("Preparing bootloader: {}", self.bootloader.name());
370        }
371        let bootloader_files = self.bootloader.prepare(&ctx)?;
372
373        // Get config files and process templates
374        let config_files = self.bootloader.config_files(&ctx)?;
375        let mut all_files = Vec::new();
376
377        // Add bootloader files
378        all_files.extend(bootloader_files.bios_files);
379        all_files.extend(bootloader_files.uefi_files);
380        all_files.extend(bootloader_files.system_files);
381
382        // Process config files with templates
383        for config_file in config_files {
384            if config_file.needs_template_processing {
385                let content = std::fs::read_to_string(&config_file.source)?;
386                let processed = self.bootloader.process_templates(&content, &ctx.template_vars)?;
387
388                let temp_path = ctx.output_dir.join("processed_config");
389                std::fs::create_dir_all(&temp_path)?;
390                let processed_file = temp_path.join(
391                    config_file
392                        .source
393                        .file_name()
394                        .ok_or_else(|| Error::config("invalid config file path"))?,
395                );
396                std::fs::write(&processed_file, processed)?;
397
398                all_files.push(crate::bootloader::FileEntry::new(
399                    processed_file,
400                    config_file.dest,
401                ));
402            } else {
403                all_files.push(crate::bootloader::FileEntry::new(
404                    config_file.source,
405                    config_file.dest,
406                ));
407            }
408        }
409
410        // Add extra files from config
411        all_files.extend(collect_extra_files(&ctx)?);
412
413        // Build image
414        if ctx.config.verbose {
415            println!("Building image: {}", self.image_builder.name());
416        }
417        let image_path = self.image_builder.build(&ctx, &all_files)?;
418
419        // Run image
420        if ctx.config.verbose {
421            println!("Running with: {}", self.runner.name());
422        }
423
424        let mut result = if let Some(ref mut handler) = self.io_handler {
425            self.runner.run_with_io(&ctx, &image_path, handler.as_mut())?
426        } else {
427            self.runner.run(&ctx, &image_path)?
428        };
429
430        // Populate captured_output from handler.finish() if available
431        if let Some(handler) = self.io_handler {
432            if let Some(captured_io) = handler.finish() {
433                let serial_str = String::from_utf8_lossy(&captured_io.serial).into_owned();
434                result = result.with_serial(serial_str);
435            }
436        }
437
438        Ok(result)
439    }
440}
441
442// --- Extra Files ---
443
444/// Collect extra files specified in config, resolving source paths relative to workspace root.
445fn collect_extra_files(ctx: &Context) -> Result<Vec<crate::bootloader::FileEntry>> {
446    let mut files = Vec::new();
447    for (dest, src) in &ctx.config.extra_files {
448        let source_path = ctx.workspace_root.join(src);
449        if !source_path.exists() {
450            return Err(Error::config(format!(
451                "extra file not found: {} (resolved to {})",
452                src,
453                source_path.display()
454            )));
455        }
456        // Strip leading '/' so dest is always relative to image root.
457        // Users may write "/boot/file" meaning "boot/file within the image".
458        let dest_path = PathBuf::from(dest.strip_prefix('/').unwrap_or(dest));
459        files.push(crate::bootloader::FileEntry::new(
460            source_path,
461            dest_path,
462        ));
463    }
464    Ok(files)
465}
466
467// --- Factory Functions ---
468
469/// Create a bootloader from configuration.
470fn create_bootloader_from_config(config: &Config) -> Result<Box<dyn Bootloader>> {
471    match config.bootloader.kind {
472        #[cfg(feature = "limine")]
473        BootloaderKind::Limine => Ok(Box::new(crate::bootloader::limine::LimineBootloader::new())),
474
475        #[cfg(not(feature = "limine"))]
476        BootloaderKind::Limine => Err(Error::feature_not_enabled("limine")),
477
478        BootloaderKind::Grub => Ok(Box::new(crate::bootloader::grub::GrubBootloader::new())),
479
480        BootloaderKind::None => Ok(Box::new(crate::bootloader::none::NoneBootloader::new())),
481    }
482}
483
484/// Create an image builder from configuration.
485fn create_image_builder_from_config(config: &Config) -> Result<Box<dyn ImageBuilder>> {
486    match config.image.format {
487        #[cfg(feature = "iso")]
488        ImageFormat::Iso => Ok(Box::new(crate::image::iso::IsoImageBuilder::new())),
489
490        #[cfg(not(feature = "iso"))]
491        ImageFormat::Iso => Err(Error::feature_not_enabled("iso")),
492
493        #[cfg(feature = "fat")]
494        ImageFormat::Fat => Ok(Box::new(crate::image::fat::FatImageBuilder::new())),
495
496        #[cfg(not(feature = "fat"))]
497        ImageFormat::Fat => Err(Error::feature_not_enabled("fat")),
498
499        ImageFormat::Directory => Ok(Box::new(crate::image::directory::DirectoryBuilder::new())),
500    }
501}
502
503/// Create a runner from configuration.
504fn create_runner_from_config(config: &Config) -> Result<Box<dyn Runner>> {
505    match config.runner.kind {
506        #[cfg(feature = "qemu")]
507        RunnerKind::Qemu => Ok(Box::new(crate::runner::qemu::QemuRunner::new())),
508
509        #[cfg(not(feature = "qemu"))]
510        RunnerKind::Qemu => Err(Error::feature_not_enabled("qemu")),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_builder_error_missing_config() {
520        let result = ImageRunnerBuilder::new()
521            .workspace_root("/tmp")
522            .executable("/tmp/kernel")
523            .build();
524        let err = result.err().expect("should fail");
525        assert!(err.to_string().contains("no configuration"));
526    }
527
528    #[test]
529    fn test_builder_error_missing_workspace_root() {
530        let result = ImageRunnerBuilder::new()
531            .with_config(Config::default())
532            .executable("/tmp/kernel")
533            .build();
534        let err = result.err().expect("should fail");
535        assert!(err.to_string().contains("workspace root"));
536    }
537
538    #[test]
539    fn test_builder_error_missing_executable() {
540        let result = ImageRunnerBuilder::new()
541            .with_config(Config::default())
542            .workspace_root("/tmp")
543            .build();
544        let err = result.err().expect("should fail");
545        assert!(err.to_string().contains("executable"));
546    }
547
548    #[test]
549    fn test_builder_with_none_bootloader_and_directory() {
550        let dir = tempfile::tempdir().unwrap();
551        let exe = dir.path().join("kernel");
552        std::fs::write(&exe, b"fake").unwrap();
553
554        // Config defaults: BootloaderKind::None, ImageFormat::Directory
555        let result = ImageRunnerBuilder::new()
556            .with_config(Config::default())
557            .workspace_root(dir.path())
558            .executable(&exe)
559            .build();
560        assert!(result.is_ok());
561    }
562
563    #[test]
564    fn test_builder_explicit_components() {
565        let dir = tempfile::tempdir().unwrap();
566        let exe = dir.path().join("kernel");
567        std::fs::write(&exe, b"fake").unwrap();
568
569        let result = ImageRunnerBuilder::new()
570            .with_config(Config::default())
571            .workspace_root(dir.path())
572            .executable(&exe)
573            .no_bootloader()
574            .directory_output()
575            .build();
576        assert!(result.is_ok());
577    }
578}