mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
use crate::messages::Message;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
    pub timestamp: u64,

    pub level: HealthLevel,

    pub message: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub diagnostics: Option<HashMap<String, String>>,
}

impl HealthStatus {
    /// Create a healthy status
    pub fn healthy() -> Self {
        Self::at_level(HealthLevel::Ok, "Operating normally")
    }

    /// Create a degraded status with a message
    pub fn degraded(message: impl Into<String>) -> Self {
        Self::at_level(HealthLevel::Degraded, message)
    }

    /// Create an unhealthy status with a message
    pub fn unhealthy(message: impl Into<String>) -> Self {
        Self::at_level(HealthLevel::Error, message)
    }

    /// Shared constructor behind `healthy`/`degraded`/`unhealthy`, which only differ in
    /// the health level and message.
    fn at_level(level: HealthLevel, message: impl Into<String>) -> Self {
        Self {
            timestamp: crate::prelude::now_micros(),
            level,
            message: message.into(),
            diagnostics: None,
        }
    }

    /// Add diagnostic data
    pub fn with_diagnostic(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.diagnostics
            .get_or_insert_with(HashMap::new)
            .insert(key.into(), value.into());
        self
    }

    /// Set a custom message
    pub fn with_message(mut self, message: impl Into<String>) -> Self {
        self.message = message.into();
        self
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthLevel {
    Ok,

    Degraded,

    Error,

    Unknown,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeStatus {
    pub timestamp: u64,

    pub name: String,

    pub state: NodeState,

    pub uptime: f64,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cpu_usage: Option<f32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub memory_usage: Option<u64>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub message_rate: Option<f32>,

    pub health: HealthStatus,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NodeState {
    Starting,

    Running,

    Paused,

    Stopping,

    Stopped,

    Crashed,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemCommand {
    #[serde(default)]
    pub timestamp: u64,

    pub target: String,

    pub command: Command,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Command {
    Start,

    Stop,

    Pause,

    Resume,

    Restart,

    Trigger,

    HealthCheck,

    ReloadConfig,

    Shutdown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
    Trace,
    Debug,
    Info,
    Warn,
    Error,
}

impl From<tracing::Level> for LogLevel {
    fn from(level: tracing::Level) -> Self {
        match level {
            tracing::Level::TRACE => LogLevel::Trace,
            tracing::Level::DEBUG => LogLevel::Debug,
            tracing::Level::INFO => LogLevel::Info,
            tracing::Level::WARN => LogLevel::Warn,
            tracing::Level::ERROR => LogLevel::Error,
        }
    }
}

impl std::fmt::Display for LogLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LogLevel::Trace => write!(f, "trace"),
            LogLevel::Debug => write!(f, "debug"),
            LogLevel::Info => write!(f, "info"),
            LogLevel::Warn => write!(f, "warn"),
            LogLevel::Error => write!(f, "error"),
        }
    }
}

impl From<&str> for LogLevel {
    /// Case-insensitively parse a level name (as found in a free-text log
    /// line or a config value), defaulting to [`LogLevel::Info`] for
    /// anything unrecognized rather than failing - this is used for
    /// best-effort classification, not strict validation.
    fn from(s: &str) -> Self {
        match s.to_lowercase().as_str() {
            "trace" => LogLevel::Trace,
            "debug" => LogLevel::Debug,
            "info" => LogLevel::Info,
            "warn" | "warning" => LogLevel::Warn,
            "error" | "err" => LogLevel::Error,
            _ => LogLevel::Info,
        }
    }
}

impl LogLevel {
    /// Ordinal severity, low to high (`Trace` = 0 .. `Error` = 4). Used to
    /// compare levels, e.g. "is this record at least as severe as the
    /// configured minimum" (see [`LogLevel::at_least`]).
    pub fn severity(self) -> u8 {
        match self {
            LogLevel::Trace => 0,
            LogLevel::Debug => 1,
            LogLevel::Info => 2,
            LogLevel::Warn => 3,
            LogLevel::Error => 4,
        }
    }

    /// Whether this level is at least as severe as `min_level` - the
    /// standard "should this be forwarded/displayed given a minimum level
    /// filter" check.
    pub fn at_least(self, min_level: LogLevel) -> bool {
        self.severity() >= min_level.severity()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogRecord {
    pub node_id: String,

    pub level: LogLevel,

    pub target: String,

    pub message: String,

    pub timestamp: u64,
}

impl Message for LogRecord {}

macro_rules! system_command_ctor {
    ($(#[$meta:meta])* $name:ident, $variant:expr) => {
        $(#[$meta])*
        pub fn $name(target: impl Into<String>) -> Self {
            Self {
                timestamp: crate::prelude::now_micros(),
                target: target.into(),
                command: $variant,
            }
        }
    };
}

impl SystemCommand {
    system_command_ctor!(
        /// Create a start command
        start,
        Command::Start
    );

    system_command_ctor!(
        /// Create a stop command
        stop,
        Command::Stop
    );

    system_command_ctor!(
        /// Create a restart command
        restart,
        Command::Restart
    );

    system_command_ctor!(
        /// Create a trigger command (fire a node-specific single-shot action)
        trigger,
        Command::Trigger
    );

    system_command_ctor!(
        /// Create a health check request
        health_check,
        Command::HealthCheck
    );
}

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

    #[test]
    fn from_str_parses_known_names_case_insensitively_and_defaults_to_info() {
        assert_eq!(LogLevel::from("TRACE"), LogLevel::Trace);
        assert_eq!(LogLevel::from("Debug"), LogLevel::Debug);
        assert_eq!(LogLevel::from("info"), LogLevel::Info);
        assert_eq!(LogLevel::from("warn"), LogLevel::Warn);
        assert_eq!(LogLevel::from("warning"), LogLevel::Warn);
        assert_eq!(LogLevel::from("error"), LogLevel::Error);
        assert_eq!(LogLevel::from("err"), LogLevel::Error);
        assert_eq!(LogLevel::from("nonsense"), LogLevel::Info);
    }

    #[test]
    fn severity_increases_from_trace_to_error() {
        assert!(LogLevel::Trace.severity() < LogLevel::Debug.severity());
        assert!(LogLevel::Debug.severity() < LogLevel::Info.severity());
        assert!(LogLevel::Info.severity() < LogLevel::Warn.severity());
        assert!(LogLevel::Warn.severity() < LogLevel::Error.severity());
    }

    #[test]
    fn at_least_compares_by_severity() {
        assert!(LogLevel::Error.at_least(LogLevel::Warn));
        assert!(LogLevel::Warn.at_least(LogLevel::Warn));
        assert!(!LogLevel::Debug.at_least(LogLevel::Warn));
    }
}

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

    #[test]
    fn start_targets_the_given_node_and_tags_the_start_command() {
        let cmd = SystemCommand::start("robot-1");
        assert_eq!(cmd.target, "robot-1");
        assert_eq!(cmd.command, Command::Start);
    }

    #[test]
    fn stop_targets_the_given_node_and_tags_the_stop_command() {
        let cmd = SystemCommand::stop("robot-1");
        assert_eq!(cmd.target, "robot-1");
        assert_eq!(cmd.command, Command::Stop);
    }

    #[test]
    fn restart_targets_the_given_node_and_tags_the_restart_command() {
        let cmd = SystemCommand::restart("robot-1");
        assert_eq!(cmd.target, "robot-1");
        assert_eq!(cmd.command, Command::Restart);
    }

    #[test]
    fn trigger_targets_the_given_node_and_tags_the_trigger_command() {
        let cmd = SystemCommand::trigger("image-classifier");
        assert_eq!(cmd.target, "image-classifier");
        assert_eq!(cmd.command, Command::Trigger);
    }

    #[test]
    fn health_check_targets_the_given_node_and_tags_the_health_check_command() {
        let cmd = SystemCommand::health_check("robot-1");
        assert_eq!(cmd.target, "robot-1");
        assert_eq!(cmd.command, Command::HealthCheck);
    }

    #[test]
    fn each_constructor_accepts_any_impl_into_string_target() {
        let owned = SystemCommand::start(String::from("robot-2"));
        let borrowed = SystemCommand::start("robot-2");
        assert_eq!(owned.target, borrowed.target);
    }
}