runmat-runtime 0.4.1

Core runtime for RunMat with builtins, BLAS/LAPACK integration, and execution APIs
Documentation
use base64::Engine;
use chrono::Utc;
use runmat_builtins::Value;
use serde::{Deserialize, Serialize};

use crate::builtins::io::mat::load::decode_workspace_from_mat_bytes;
use crate::builtins::io::mat::save::encode_workspace_to_mat_bytes;
use crate::replay::limits::ReplayLimits;
use crate::runtime_error::{replay_error, replay_error_with_source, ReplayErrorKind};
use crate::{BuiltinResult, RuntimeError};

const WORKSPACE_SCHEMA_VERSION: u32 = 1;
const WORKSPACE_KIND: &str = "workspace-state";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceReplayMode {
    Auto,
    Force,
    Off,
}

impl WorkspaceReplayMode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::Force => "force",
            Self::Off => "off",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceReplayPayload {
    schema_version: u32,
    kind: String,
    created_at: String,
    mode: String,
    mat_base64: String,
}

pub async fn encode_workspace_payload(
    entries: &[(String, Value)],
    mode: &str,
) -> BuiltinResult<Vec<u8>> {
    encode_workspace_payload_with_limits(entries, mode, ReplayLimits::default()).await
}

pub async fn export_workspace_state(
    entries: &[(String, Value)],
    mode: WorkspaceReplayMode,
) -> BuiltinResult<Option<Vec<u8>>> {
    if matches!(mode, WorkspaceReplayMode::Off) {
        return Ok(None);
    }
    encode_workspace_payload(entries, mode.as_str())
        .await
        .map(Some)
}

pub async fn encode_workspace_payload_with_limits(
    entries: &[(String, Value)],
    mode: &str,
    limits: ReplayLimits,
) -> BuiltinResult<Vec<u8>> {
    validate_workspace_mode(mode)?;
    if entries.len() > limits.max_workspace_variables {
        return Err(replay_error(
            ReplayErrorKind::ImportRejected,
            format!(
                "workspace export includes {} variables, exceeding limit {}",
                entries.len(),
                limits.max_workspace_variables
            ),
        ));
    }

    let mat_bytes = encode_workspace_to_mat_bytes(entries).await?;
    if mat_bytes.len() > limits.max_workspace_mat_bytes {
        return Err(replay_error(
            ReplayErrorKind::PayloadTooLarge,
            format!(
                "workspace MAT payload is {} bytes, exceeding limit {}",
                mat_bytes.len(),
                limits.max_workspace_mat_bytes
            ),
        ));
    }

    let payload = WorkspaceReplayPayload {
        schema_version: WORKSPACE_SCHEMA_VERSION,
        kind: WORKSPACE_KIND.to_string(),
        created_at: Utc::now().to_rfc3339(),
        mode: mode.to_string(),
        mat_base64: base64::engine::general_purpose::STANDARD.encode(mat_bytes),
    };

    let encoded = serde_json::to_vec(&payload).map_err(|err| {
        replay_error_with_source(
            ReplayErrorKind::DecodeFailed,
            "failed to encode workspace replay payload",
            err,
        )
    })?;

    if encoded.len() > limits.max_workspace_payload_bytes {
        return Err(replay_error(
            ReplayErrorKind::PayloadTooLarge,
            format!(
                "workspace replay payload is {} bytes, exceeding limit {}",
                encoded.len(),
                limits.max_workspace_payload_bytes
            ),
        ));
    }

    Ok(encoded)
}

pub fn decode_workspace_payload(bytes: &[u8]) -> BuiltinResult<Vec<(String, Value)>> {
    decode_workspace_payload_with_limits(bytes, ReplayLimits::default())
}

pub fn import_workspace_state(bytes: &[u8]) -> BuiltinResult<Vec<(String, Value)>> {
    decode_workspace_payload(bytes)
}

pub fn decode_workspace_payload_with_limits(
    bytes: &[u8],
    limits: ReplayLimits,
) -> BuiltinResult<Vec<(String, Value)>> {
    if bytes.len() > limits.max_workspace_payload_bytes {
        return Err(replay_error(
            ReplayErrorKind::PayloadTooLarge,
            format!(
                "workspace replay payload is {} bytes, exceeding limit {}",
                bytes.len(),
                limits.max_workspace_payload_bytes
            ),
        ));
    }

    let payload: WorkspaceReplayPayload = serde_json::from_slice(bytes).map_err(|err| {
        replay_error_with_source(
            ReplayErrorKind::DecodeFailed,
            "failed to decode workspace replay payload",
            err,
        )
    })?;

    if payload.schema_version != WORKSPACE_SCHEMA_VERSION {
        return Err(replay_error(
            ReplayErrorKind::UnsupportedSchema,
            format!(
                "unsupported workspace replay schema version {}",
                payload.schema_version
            ),
        ));
    }
    if payload.kind != WORKSPACE_KIND {
        return Err(replay_error(
            ReplayErrorKind::ImportRejected,
            format!("unexpected replay payload kind '{}'", payload.kind),
        ));
    }
    validate_workspace_mode(&payload.mode)?;

    let mat_bytes = base64::engine::general_purpose::STANDARD
        .decode(payload.mat_base64.as_bytes())
        .map_err(|err| {
            replay_error_with_source(
                ReplayErrorKind::DecodeFailed,
                "failed to decode workspace replay MAT bytes",
                err,
            )
        })?;

    if mat_bytes.len() > limits.max_workspace_mat_bytes {
        return Err(replay_error(
            ReplayErrorKind::PayloadTooLarge,
            format!(
                "workspace MAT payload is {} bytes, exceeding limit {}",
                mat_bytes.len(),
                limits.max_workspace_mat_bytes
            ),
        ));
    }

    let entries = decode_workspace_from_mat_bytes(&mat_bytes).map_err(|err| {
        replay_error_with_source(
            ReplayErrorKind::DecodeFailed,
            "failed to decode workspace MAT payload",
            err,
        )
    })?;
    if entries.len() > limits.max_workspace_variables {
        return Err(replay_error(
            ReplayErrorKind::ImportRejected,
            format!(
                "workspace payload includes {} variables, exceeding limit {}",
                entries.len(),
                limits.max_workspace_variables
            ),
        ));
    }
    Ok(entries)
}

fn validate_workspace_mode(mode: &str) -> Result<(), RuntimeError> {
    if matches!(mode, "auto" | "force") {
        Ok(())
    } else {
        Err(replay_error(
            ReplayErrorKind::ImportRejected,
            format!("workspace replay mode '{mode}' is not supported"),
        ))
    }
}

#[cfg(test)]
mod tests {
    use futures::executor::block_on;

    use super::*;

    #[test]
    fn workspace_schema_mismatch_rejects() {
        let payload = serde_json::json!({
            "schemaVersion": 99,
            "kind": WORKSPACE_KIND,
            "createdAt": "2026-01-01T00:00:00Z",
            "mode": "auto",
            "matBase64": ""
        });
        let bytes = serde_json::to_vec(&payload).expect("serialize payload");
        let err = decode_workspace_payload_with_limits(&bytes, ReplayLimits::default())
            .expect_err("expected schema rejection");
        assert_eq!(
            err.identifier(),
            Some(ReplayErrorKind::UnsupportedSchema.identifier())
        );
    }

    #[test]
    fn workspace_payload_too_large_rejects() {
        let bytes = vec![0u8; ReplayLimits::default().max_workspace_payload_bytes + 1];
        let err = decode_workspace_payload_with_limits(&bytes, ReplayLimits::default())
            .expect_err("expected payload rejection");
        assert_eq!(
            err.identifier(),
            Some(ReplayErrorKind::PayloadTooLarge.identifier())
        );
    }

    #[test]
    fn workspace_variable_count_limit_rejects() {
        let entries = vec![
            ("a".to_string(), Value::Num(1.0)),
            ("b".to_string(), Value::Num(2.0)),
        ];
        let bytes = block_on(encode_workspace_payload_with_limits(
            &entries,
            "auto",
            ReplayLimits {
                max_workspace_variables: 4,
                ..ReplayLimits::default()
            },
        ))
        .expect("encode workspace payload");

        let err = decode_workspace_payload_with_limits(
            &bytes,
            ReplayLimits {
                max_workspace_variables: 1,
                ..ReplayLimits::default()
            },
        )
        .expect_err("expected variable limit rejection");
        assert_eq!(
            err.identifier(),
            Some(ReplayErrorKind::ImportRejected.identifier())
        );
    }
}