hilt 0.2.0

Renode-based hardware-in-the-loop test fixtures for embedded Rust projects
Documentation
//! Captured output of a HIL run and the `assert_hil_ok!` macro.

use std::fmt;

/// Success marker firmware logs (via a CPU hook or semihosting print).
pub const HIL_OK_MARKER: &str = "HIL OK";

/// Failure marker logged when firmware reaches the fail symbol
/// ([`HilConfig::fail_marker`](crate::HilConfig::fail_marker)) or its panic
/// handler.
pub const HIL_FAIL_MARKER: &str = "HIL FAIL";

/// Tag Renode's logging UART analyzer puts before each captured line:
/// `uart0: [host: 1.2ms (+1.2ms)|virt: 0s (+0s)] <line>`.
const UART_LINE_TAG: &str = ": [host: ";

/// Result of a HIL run: captured stdout/stderr and the container exit code.
#[derive(Debug, Clone)]
pub struct HilOutput {
    stdout: String,
    stderr: String,
    exit_code: Option<i32>,
    timed_out: bool,
}

impl HilOutput {
    /// Builds an output from captured streams, exit code, and whether the
    /// run was killed by the wall-clock timeout rather than finishing on its
    /// own.
    #[must_use]
    pub fn new(stdout: String, stderr: String, exit_code: Option<i32>, timed_out: bool) -> Self {
        Self {
            stdout,
            stderr,
            exit_code,
            timed_out,
        }
    }

    /// `true` if the [`HIL_OK_MARKER`] was logged and the firmware did not
    /// report failure.
    #[must_use]
    pub fn passed(&self) -> bool {
        self.contains(HIL_OK_MARKER) && !self.failed()
    }

    /// `true` if the firmware reported failure ([`HIL_FAIL_MARKER`]): it
    /// reached the fail symbol or panicked.
    #[must_use]
    pub fn failed(&self) -> bool {
        self.contains(HIL_FAIL_MARKER)
    }

    /// `true` if either stream or the captured [`uart`](Self::uart) text
    /// contains `marker`.
    #[must_use]
    pub fn contains(&self, marker: &str) -> bool {
        self.stdout.contains(marker) || self.stderr.contains(marker) || self.uart().contains(marker)
    }

    /// Text the firmware wrote to its console UART ([`Platform::uart`]), one
    /// line per UART line, with Renode's log prefixes stripped. Empty if the
    /// platform has no UART or nothing was written.
    ///
    /// [`Platform::uart`]: crate::Platform::uart
    #[must_use]
    pub fn uart(&self) -> String {
        let mut text = String::new();
        for line in self.stdout.lines() {
            let Some(i) = line.find(UART_LINE_TAG) else {
                continue;
            };
            let Some(j) = line[i..].find("] ") else {
                continue;
            };
            text.push_str(&line[i + j + 2..]);
            text.push('\n');
        }
        text
    }

    /// Captured stdout.
    #[must_use]
    pub fn stdout(&self) -> &str {
        &self.stdout
    }

    /// Captured stderr.
    #[must_use]
    pub fn stderr(&self) -> &str {
        &self.stderr
    }

    /// Stdout and stderr joined with a newline — convenient for log scraping.
    #[must_use]
    pub fn combined(&self) -> String {
        format!("{}\n{}", self.stdout, self.stderr)
    }

    /// Container process exit code, if the process exited normally.
    #[must_use]
    pub fn exit_code(&self) -> Option<i32> {
        self.exit_code
    }

    /// `true` if the run was killed by the wall-clock timeout rather than
    /// finishing (and exiting) on its own -- distinguishes "the firmware
    /// hung" from "Renode exited and the marker just wasn't logged."
    #[must_use]
    pub fn timed_out(&self) -> bool {
        self.timed_out
    }
}

impl fmt::Display for HilOutput {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let code = match self.exit_code {
            Some(c) => c.to_string(),
            None => "none".to_string(),
        };
        write!(
            f,
            "exit={code}\ntimed_out={}\n--- stdout ---\n{}\n--- stderr ---\n{}",
            self.timed_out, self.stdout, self.stderr
        )
    }
}

/// Asserts that a [`HilOutput`] passed.
///
/// `assert_hil_ok!(output)` checks for `HIL OK`; `assert_hil_ok!(output,
/// marker)` checks for a custom marker. Either form fails first, with a
/// distinct message, if the firmware reported failure (`HIL FAIL`). On failure
/// it prints the full output.
#[macro_export]
macro_rules! assert_hil_ok {
    ($output:expr) => {
        $crate::assert_hil_ok!($output, $crate::HIL_OK_MARKER)
    };
    ($output:expr, $marker:expr) => {
        assert!(
            !$output.failed(),
            "HIL firmware reported FAILURE (fail marker or panic).\n{}",
            $output,
        );
        assert!(
            $output.contains($marker),
            "HIL test failed -- expected {:?}.\n{}",
            $marker,
            $output,
        )
    };
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn passed_detects_marker_in_either_stream() {
        assert!(HilOutput::new("boot\nHIL OK\n".into(), String::new(), Some(0), false).passed());
        assert!(HilOutput::new(String::new(), "HIL OK".into(), Some(0), false).passed());
        assert!(!HilOutput::new("timeout".into(), String::new(), Some(1), false).passed());
    }

    #[test]
    fn fail_marker_overrides_pass() {
        let o = HilOutput::new(
            "HIL OK\nHIL FAIL (panic)".into(),
            String::new(),
            Some(0),
            false,
        );
        assert!(o.failed());
        assert!(!o.passed());
    }

    #[test]
    #[should_panic(expected = "reported FAILURE")]
    fn assert_macro_reports_failure() {
        let o = HilOutput::new("HIL FAIL".into(), String::new(), Some(0), false);
        assert_hil_ok!(o);
    }

    #[test]
    fn uart_strips_analyzer_prefix() {
        let log = "20:11:03.3151 [INFO] uart0: [host: 34.36ms (+34.36ms)|virt: 0s (+0s)] hello\n\
                   20:11:03.3338 [WARNING] cpu: HIL OK\n\
                   20:11:03.3400 [INFO] hil/uart0: [host: 35ms (+1ms)|virt: 0s (+0s)] world";
        let o = HilOutput::new(log.into(), String::new(), Some(0), false);
        assert_eq!(o.uart(), "hello\nworld\n");
        assert!(o.contains("hello\nworld"));
    }

    #[test]
    fn contains_and_combined() {
        let o = HilOutput::new("hello".into(), "world".into(), Some(0), false);
        assert!(o.contains("hello"));
        assert!(o.contains("world"));
        assert_eq!(o.combined(), "hello\nworld");
    }

    #[test]
    fn display_includes_code_and_streams() {
        let s = HilOutput::new("hi".into(), "there".into(), Some(42), false).to_string();
        assert!(s.contains("42"));
        assert!(s.contains("hi"));
        assert!(s.contains("there"));
        let none = HilOutput::new(String::new(), String::new(), None, false).to_string();
        assert!(none.contains("exit=none"));
    }

    #[test]
    fn display_includes_timed_out() {
        let s = HilOutput::new(String::new(), String::new(), None, true).to_string();
        assert!(s.contains("timed_out=true"));
    }

    #[test]
    fn assert_macro_passes() {
        let o = HilOutput::new("HIL OK".into(), String::new(), Some(0), false);
        assert_hil_ok!(o);
        let o2 = HilOutput::new("CUSTOM".into(), String::new(), Some(0), false);
        assert_hil_ok!(o2, "CUSTOM");
    }
}