ready-active-safe 0.1.3

Lifecycle engine for externally driven systems
Documentation
//! Transition recording and deterministic replay.
//!
//! The journal is an observer: it records what happened, but it does not
//! influence decisions or modify modes.
//!
//! This module is intentionally in-memory only. Persistence is an adapter
//! concern and belongs outside the core crate.

use alloc::vec::Vec;

use crate::Machine;

/// A record of one processed event.
///
/// A record captures the before/after modes and the emitted commands so a system
/// can be audited or replayed deterministically.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransitionRecord<M, E, C> {
    /// Sequential step index (0-based) within the journal.
    pub step: usize,
    /// Mode before processing the event.
    pub from: M,
    /// Mode after applying the decision.
    pub to: M,
    /// The processed event.
    pub event: E,
    /// Commands emitted by the machine, in order.
    pub commands: Vec<C>,
    /// Timestamp recorded by the journal.
    pub timestamp: std::time::SystemTime,
}

impl<M, E, C> TransitionRecord<M, E, C> {
    /// Creates a new transition record.
    #[must_use]
    pub fn new(step: usize, from: M, to: M, event: E, commands: Vec<C>) -> Self {
        Self {
            step,
            from,
            to,
            event,
            commands,
            timestamp: std::time::SystemTime::now(),
        }
    }
}

/// An in-memory journal of [`TransitionRecord`] values.
#[derive(Debug, Default)]
pub struct InMemoryJournal<M, E, C> {
    records: Vec<TransitionRecord<M, E, C>>,
}

impl<M, E, C> InMemoryJournal<M, E, C> {
    /// Creates an empty journal.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            records: Vec::new(),
        }
    }

    /// Returns the number of records in the journal.
    #[must_use]
    pub fn len(&self) -> usize {
        self.records.len()
    }

    /// Returns `true` if the journal is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.records.is_empty()
    }

    /// Returns a slice of all records.
    #[must_use]
    pub fn records(&self) -> &[TransitionRecord<M, E, C>] {
        &self.records
    }

    /// Appends a record.
    pub fn append(&mut self, record: TransitionRecord<M, E, C>) {
        self.records.push(record);
    }

    /// Returns the most recent record, if any.
    #[must_use]
    pub fn last(&self) -> Option<&TransitionRecord<M, E, C>> {
        self.records.last()
    }

    /// Records a step by cloning the provided values.
    ///
    /// This is a convenience for runtimes that keep `Mode` and `Event` by
    /// reference and want to capture an owned record.
    pub fn record_step(&mut self, from: &M, to: &M, event: &E, commands: &[C])
    where
        M: Clone,
        E: Clone,
        C: Clone,
    {
        let step = self.records.len();
        self.append(TransitionRecord::new(
            step,
            from.clone(),
            to.clone(),
            event.clone(),
            commands.to_vec(),
        ));
    }

    /// Returns an iterator over records whose `from` mode matches `mode`.
    pub fn transitions_from<'a>(
        &'a self,
        mode: &'a M,
    ) -> impl Iterator<Item = &'a TransitionRecord<M, E, C>> + 'a
    where
        M: PartialEq,
    {
        self.records.iter().filter(move |r| &r.from == mode)
    }

    /// Returns an iterator over records whose `to` mode matches `mode`.
    pub fn transitions_to<'a>(
        &'a self,
        mode: &'a M,
    ) -> impl Iterator<Item = &'a TransitionRecord<M, E, C>> + 'a
    where
        M: PartialEq,
    {
        self.records.iter().filter(move |r| &r.to == mode)
    }

    /// Replays all records through `machine`, starting from `initial_mode`.
    ///
    /// On success, returns the final mode after applying all decisions.
    ///
    /// # Errors
    ///
    /// Returns a [`ReplayError`] if replay diverges from the recorded history.
    pub fn replay<Mac>(&self, machine: &Mac, initial_mode: M) -> Result<M, ReplayError>
    where
        Mac: Machine<Mode = M, Event = E, Command = C>,
        M: Clone + PartialEq,
        C: PartialEq,
    {
        self.replay_with_error::<core::convert::Infallible, _>(machine, initial_mode)
    }

    /// Replays all records through `machine` when the machine uses a non-default error type.
    ///
    /// This is identical to [`InMemoryJournal::replay`], but works with machines that are
    /// implemented as `Machine<Err>` rather than `Machine` (where `Err` is the default
    /// `core::convert::Infallible`).
    ///
    /// # Errors
    ///
    /// Returns a [`ReplayError`] if replay diverges from the recorded history.
    pub fn replay_with_error<Err, Mac>(
        &self,
        machine: &Mac,
        initial_mode: M,
    ) -> Result<M, ReplayError>
    where
        Mac: Machine<Err, Mode = M, Event = E, Command = C>,
        M: Clone + PartialEq,
        C: PartialEq,
    {
        let mut mode = initial_mode;

        for record in &self.records {
            if mode != record.from {
                return Err(ReplayError::new(record.step, ReplayMismatch::FromMode));
            }

            let decision = machine.decide(&mode, &record.event);
            let (next_mode, commands) = decision.apply(mode.clone());

            if next_mode != record.to {
                return Err(ReplayError::new(record.step, ReplayMismatch::ToMode));
            }

            if commands.as_slice() != record.commands.as_slice() {
                return Err(ReplayError::new(record.step, ReplayMismatch::Commands));
            }

            mode = next_mode;
        }

        Ok(mode)
    }
}

/// The type of mismatch encountered during replay.
///
/// # Examples
///
/// ```
/// use ready_active_safe::journal::ReplayMismatch;
///
/// let m = ReplayMismatch::FromMode;
/// assert_eq!(m.to_string(), "current mode differs from recorded starting mode");
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReplayMismatch {
    /// The current mode at a step differs from the recorded `from` mode.
    FromMode,
    /// The computed next mode differs from the recorded `to` mode.
    ToMode,
    /// The computed commands differ from the recorded `commands`.
    Commands,
}

impl core::fmt::Display for ReplayMismatch {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::FromMode => {
                write!(f, "current mode differs from recorded starting mode")
            }
            Self::ToMode => {
                write!(f, "computed next mode differs from recorded mode")
            }
            Self::Commands => {
                write!(f, "computed commands differ from recorded commands")
            }
        }
    }
}

/// An error returned by [`InMemoryJournal::replay`].
///
/// # Examples
///
/// ```
/// use ready_active_safe::journal::{ReplayError, ReplayMismatch};
///
/// let err = ReplayError::new(3, ReplayMismatch::ToMode);
/// assert_eq!(err.step(), 3);
/// assert_eq!(err.mismatch(), ReplayMismatch::ToMode);
/// assert_eq!(
///     err.to_string(),
///     "replay diverged at step 3: computed next mode differs from recorded mode",
/// );
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplayError {
    step: usize,
    mismatch: ReplayMismatch,
}

impl core::fmt::Display for ReplayError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(
            f,
            "replay diverged at step {}: {}",
            self.step, self.mismatch,
        )
    }
}

impl std::error::Error for ReplayError {}

impl ReplayError {
    /// Creates a new replay error.
    #[must_use]
    pub const fn new(step: usize, mismatch: ReplayMismatch) -> Self {
        Self { step, mismatch }
    }

    /// Returns the step at which replay diverged.
    #[must_use]
    pub const fn step(&self) -> usize {
        self.step
    }

    /// Returns the kind of mismatch encountered.
    #[must_use]
    pub const fn mismatch(&self) -> ReplayMismatch {
        self.mismatch
    }
}