batpak 0.3.0

Event sourcing with causal graphs and policy gates. Sync API, zero async.
Documentation
use serde::{Deserialize, Serialize};
// NOTE: No `use crate::wire::*` needed. serde(with = "crate::wire::...") resolves via string path.

/// Combinators for merging multiple outcomes (join_all, join_any, zip).
pub mod combine;
/// Structured error types used in `Outcome::Err`.
pub mod error;
/// Wait condition and compensation action types for `Outcome::Pending` and errors.
pub mod wait;

pub use combine::{join_all, join_any, zip};
pub use error::{ErrorKind, OutcomeError};
pub use wait::{CompensationAction, WaitCondition};

/// `Outcome<T>`: the core algebraic type. 6 variants.
/// Named "Outcome" not "Effect" to eliminate Effect/Event confusion.
/// [SPEC:src/outcome/mod.rs]

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
#[non_exhaustive]
pub enum Outcome<T> {
    /// The operation succeeded and produced a value.
    Ok(T),
    /// The operation failed with a structured error.
    Err(OutcomeError),
    /// The operation should be retried after a delay.
    Retry {
        /// Milliseconds to wait before retrying.
        after_ms: u64,
        /// Current attempt number (1-based).
        attempt: u32,
        /// Maximum number of attempts before giving up.
        max_attempts: u32,
        /// Human-readable reason for the retry.
        reason: String,
    },
    /// The operation is waiting on an external condition to resume.
    Pending {
        /// The condition that must be satisfied before resuming.
        condition: WaitCondition,
        /// Opaque token used to correlate the resume event with this pending outcome.
        #[serde(with = "crate::wire::u128_bytes")]
        resume_token: u128,
    },
    /// The operation was explicitly cancelled.
    Cancelled {
        /// Human-readable reason for the cancellation.
        reason: String,
    },
    /// A collection of outcomes to be processed together.
    Batch(Vec<Outcome<T>>),
}

// Monadic combinators intentionally use wildcard matches for pass-through patterns.
// Each combinator handles 1-2 specific variants and passes the rest unchanged.
#[allow(clippy::wildcard_enum_match_arm)]
impl<T> Outcome<T> {
    // --- Construction ---
    /// Creates a successful outcome wrapping the given value.
    pub fn ok(val: T) -> Self {
        Self::Ok(val)
    }
    /// Creates a failed outcome wrapping the given error.
    pub fn err(e: OutcomeError) -> Self {
        Self::Err(e)
    }
    /// Creates a cancelled outcome with the given reason.
    pub fn cancelled(reason: impl Into<String>) -> Self {
        Self::Cancelled {
            reason: reason.into(),
        }
    }
    /// Creates a retry outcome with delay, attempt counters, and a reason.
    pub fn retry(
        after_ms: u64,
        attempt: u32,
        max_attempts: u32,
        reason: impl Into<String>,
    ) -> Self {
        Self::Retry {
            after_ms,
            attempt,
            max_attempts,
            reason: reason.into(),
        }
    }
    /// Creates a pending outcome that waits on the given condition and resume token.
    pub fn pending(condition: WaitCondition, resume_token: u128) -> Self {
        Self::Pending {
            condition,
            resume_token,
        }
    }

    // --- Predicates ---
    /// Returns true if this outcome is `Ok`.
    pub fn is_ok(&self) -> bool {
        matches!(self, Self::Ok(_))
    }
    /// Returns true if this outcome is `Err`.
    pub fn is_err(&self) -> bool {
        matches!(self, Self::Err(_))
    }
    /// Returns true if this outcome is `Retry`.
    pub fn is_retry(&self) -> bool {
        matches!(self, Self::Retry { .. })
    }
    /// Returns true if this outcome is `Pending`.
    pub fn is_pending(&self) -> bool {
        matches!(self, Self::Pending { .. })
    }
    /// Returns true if this outcome is `Cancelled`.
    pub fn is_cancelled(&self) -> bool {
        matches!(self, Self::Cancelled { .. })
    }
    /// Returns true if this outcome is `Batch`.
    pub fn is_batch(&self) -> bool {
        matches!(self, Self::Batch(_))
    }
    /// Returns true if this outcome is terminal (`Ok`, `Err`, or `Cancelled`).
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Ok(_) | Self::Err(_) | Self::Cancelled { .. })
    }

    // --- Combinators ---

    /// map: transform the Ok value. Distributes over Batch.
    /// [SPEC:src/outcome/mod.rs — combinators distribute over Batch via F: Clone]
    ///
    /// # Example
    /// ```
    /// use batpak::prelude::*;
    /// let doubled: Outcome<i32> = Outcome::ok(21).map(|x| x * 2);
    /// assert_eq!(doubled, Outcome::ok(42));
    /// ```
    pub fn map<U, F: FnOnce(T) -> U + Clone>(self, f: F) -> Outcome<U> {
        match self {
            Self::Ok(v) => Outcome::Ok(f(v)),
            Self::Err(e) => Outcome::Err(e),
            Self::Retry {
                after_ms,
                attempt,
                max_attempts,
                reason,
            } => Outcome::Retry {
                after_ms,
                attempt,
                max_attempts,
                reason,
            },
            Self::Pending {
                condition,
                resume_token,
            } => Outcome::Pending {
                condition,
                resume_token,
            },
            Self::Cancelled { reason } => Outcome::Cancelled { reason },
            Self::Batch(items) => {
                Outcome::Batch(items.into_iter().map(|o| o.map(f.clone())).collect())
            }
        }
    }

    /// and_then: the monad bind. Distributes over Batch.
    /// F: Clone is required for Batch distribution (called once per element).
    /// [SPEC:src/outcome/mod.rs — The and_then monad fix]
    /// This is THE critical method. Monad laws are verified by proptest.
    /// [FILE:tests/monad_laws.rs]
    pub fn and_then<U, F: FnOnce(T) -> Outcome<U> + Clone>(self, f: F) -> Outcome<U> {
        match self {
            Self::Ok(v) => f(v),
            Self::Err(e) => Outcome::Err(e),
            Self::Retry {
                after_ms,
                attempt,
                max_attempts,
                reason,
            } => Outcome::Retry {
                after_ms,
                attempt,
                max_attempts,
                reason,
            },
            Self::Pending {
                condition,
                resume_token,
            } => Outcome::Pending {
                condition,
                resume_token,
            },
            Self::Cancelled { reason } => Outcome::Cancelled { reason },
            Self::Batch(items) => {
                Outcome::Batch(items.into_iter().map(|o| o.and_then(f.clone())).collect())
            }
        }
    }

    /// Transforms the `Err` value using `f`, leaving all other variants unchanged.
    pub fn map_err<F: FnOnce(OutcomeError) -> OutcomeError + Clone>(self, f: F) -> Self {
        match self {
            Self::Err(e) => Self::Err(f(e)),
            Self::Batch(items) => {
                Self::Batch(items.into_iter().map(|o| o.map_err(f.clone())).collect())
            }
            other => other,
        }
    }

    /// Applies `f` to recover from an `Err`, leaving all other variants unchanged.
    pub fn or_else<F: FnOnce(OutcomeError) -> Outcome<T> + Clone>(self, f: F) -> Outcome<T> {
        match self {
            Self::Err(e) => f(e),
            Self::Batch(items) => {
                Self::Batch(items.into_iter().map(|o| o.or_else(f.clone())).collect())
            }
            other => other,
        }
    }

    /// Calls `f` with a reference to the `Ok` value for side effects, then returns self unchanged.
    pub fn inspect<F: FnOnce(&T) + Clone>(self, f: F) -> Self {
        match self {
            Self::Ok(v) => {
                f(&v);
                Self::Ok(v)
            }
            Self::Batch(items) => {
                Self::Batch(items.into_iter().map(|o| o.inspect(f.clone())).collect())
            }
            other => other,
        }
    }

    /// Calls `f` with a reference to the `Err` value for side effects, then returns self unchanged.
    pub fn inspect_err<F: FnOnce(&OutcomeError) + Clone>(self, f: F) -> Self {
        match self {
            Self::Err(e) => {
                f(&e);
                Self::Err(e)
            }
            Self::Batch(items) => Self::Batch(
                items
                    .into_iter()
                    .map(|o| o.inspect_err(f.clone()))
                    .collect(),
            ),
            other => other,
        }
    }

    /// Applies `f` only when this is `Ok` and `pred` returns true; otherwise returns self unchanged.
    pub fn and_then_if<F: Fn(&T) -> bool, G: FnOnce(T) -> Outcome<T> + Clone>(
        self,
        pred: F,
        f: G,
    ) -> Outcome<T> {
        match self {
            Self::Ok(v) => {
                if pred(&v) {
                    f(v)
                } else {
                    Self::Ok(v)
                }
            }
            Self::Batch(items) => Self::Batch(
                items
                    .into_iter()
                    .map(|o| match o {
                        Self::Ok(v) => {
                            if pred(&v) {
                                f.clone()(v)
                            } else {
                                Self::Ok(v)
                            }
                        }
                        other => other,
                    })
                    .collect(),
            ),
            other => other,
        }
    }

    /// Converts this outcome into a `Result`, mapping non-terminal variants to `Err`.
    ///
    /// # Errors
    /// Returns `OutcomeError` if this outcome is `Err`, `Cancelled`, or any non-terminal variant.
    pub fn into_result(self) -> Result<T, OutcomeError> {
        match self {
            Self::Ok(v) => Ok(v),
            Self::Err(e) => Err(e),
            Self::Cancelled { reason } => Err(OutcomeError {
                kind: ErrorKind::Internal,
                message: format!("cancelled: {reason}"),
                compensation: None,
                retryable: false,
            }),
            _ => Err(OutcomeError {
                kind: ErrorKind::Internal,
                message: "outcome is not terminal".into(),
                compensation: None,
                retryable: false,
            }),
        }
    }

    /// Returns the `Ok` value or `default` if this outcome is not `Ok`.
    pub fn unwrap_or(self, default: T) -> T {
        match self {
            Self::Ok(v) => v,
            _ => default,
        }
    }

    /// Returns the `Ok` value or computes a default from `f` if this outcome is not `Ok`.
    pub fn unwrap_or_else<F: FnOnce() -> T>(self, f: F) -> T {
        match self {
            Self::Ok(v) => v,
            _ => f(),
        }
    }
}

/// flatten: unwrap one layer of nesting. `Outcome<Outcome<T>>` → `Outcome<T>`.
/// Implemented on the nested type (like Option::flatten), not as a bounded
/// method on `Outcome<T>`. This is join in category theory: join = bind(id).
/// Composes with and_then (the monad bind, proptest-proven).
#[allow(clippy::wildcard_enum_match_arm)]
impl<T> Outcome<Outcome<T>> {
    /// Unwraps one layer of nesting, equivalent to `and_then(|inner| inner)`.
    pub fn flatten(self) -> Outcome<T> {
        self.and_then(|inner| inner)
    }
}