behavior-contracts 0.2.1

Language-neutral IR runtime core (expression evaluation, template rendering, execution plan, canonical serialization) shared across DSL implementations. Passes the dsl-contracts conformance vectors byte-for-byte.
Documentation
//! envelope — validate_envelope (spec-version fail-closed check).
//!
//! Version must be `"<major>.<minor>"` (two integers). major must equal the
//! supported major; minor must be <= supported minor (minor is additive).
//! Unknown/out-of-range versions are loudly rejected.

use serde_json::Value as J;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvelopeFailureCode {
    MissingVersion,
    MalformedVersion,
    UnsupportedMajor,
    UnsupportedMinor,
}

impl EnvelopeFailureCode {
    pub fn as_str(self) -> &'static str {
        match self {
            EnvelopeFailureCode::MissingVersion => "MISSING_VERSION",
            EnvelopeFailureCode::MalformedVersion => "MALFORMED_VERSION",
            EnvelopeFailureCode::UnsupportedMajor => "UNSUPPORTED_MAJOR",
            EnvelopeFailureCode::UnsupportedMinor => "UNSUPPORTED_MINOR",
        }
    }
}

#[derive(Debug, Clone)]
pub struct EnvelopeFailure {
    pub code: EnvelopeFailureCode,
    pub message: String,
}

impl std::fmt::Display for EnvelopeFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.code.as_str(), self.message)
    }
}
impl std::error::Error for EnvelopeFailure {}

fn fail<T>(code: EnvelopeFailureCode, message: impl Into<String>) -> Result<T, EnvelopeFailure> {
    Err(EnvelopeFailure {
        code,
        message: message.into(),
    })
}

fn parse_version(version: &str, label: &str) -> Result<(u64, u64), EnvelopeFailure> {
    let parts: Vec<&str> = version.split('.').collect();
    if parts.len() != 2 {
        return fail(
            EnvelopeFailureCode::MalformedVersion,
            format!("{label}: version '{version}' must be '<major>.<minor>'"),
        );
    }
    let is_int = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
    if !is_int(parts[0]) || !is_int(parts[1]) {
        return fail(
            EnvelopeFailureCode::MalformedVersion,
            format!("{label}: version '{version}' must be two integers '<major>.<minor>'"),
        );
    }
    Ok((parts[0].parse().unwrap(), parts[1].parse().unwrap()))
}

/// Validate an envelope's spec version against `supported` (`"<major>.<minor>"`).
///
/// `field` defaults to `"specVersion"`; `label` defaults to `"envelope"`.
pub fn validate_envelope(
    envelope: &J,
    supported: &str,
    field: Option<&str>,
    label: Option<&str>,
) -> Result<(), EnvelopeFailure> {
    let field = field.unwrap_or("specVersion");
    let label = label.unwrap_or("envelope");
    let obj = match envelope {
        J::Object(o) => o,
        _ => {
            return fail(
                EnvelopeFailureCode::MissingVersion,
                format!("{label}: not an object"),
            )
        }
    };
    let raw = match obj.get(field) {
        Some(J::String(s)) => s,
        _ => {
            return fail(
                EnvelopeFailureCode::MissingVersion,
                format!("{label}: missing string '{field}'"),
            )
        }
    };

    let (gmaj, gmin) = parse_version(raw, label)?;
    let (wmaj, wmin) = parse_version(supported, "supported")?;

    if gmaj != wmaj {
        return fail(
            EnvelopeFailureCode::UnsupportedMajor,
            format!("{label}: version {raw} has major {gmaj} but runtime supports major {wmaj}"),
        );
    }
    if gmin > wmin {
        return fail(
            EnvelopeFailureCode::UnsupportedMinor,
            format!("{label}: version {raw} has minor {gmin} > supported minor {wmin}"),
        );
    }
    Ok(())
}