diff --git a/.github/extensions.toml b/.github/extensions.toml index 4f59039a4..b60d95e4a 100644 --- a/.github/extensions.toml +++ b/.github/extensions.toml @@ -3,7 +3,7 @@ package = "morphir-rust-binding" artifact = "morphir-rust-binding" extension_id = "morphir-rust" name = "Morphir Rust" -mep_versions = ["0.1"] +mep_versions = ["0.1", "0.2.0-draft.1"] targets = ["rust"] languages = [{ id = "rust", file_extensions = [".rs"] }] ir_versions = ["3", "4"] diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a703b68..50971b43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- The Rust extension (`morphir-rust-binding`) speaks MEP `0.2.0-draft.1` as + well as MEP `0.1`. On the new line it serves `morphir.frontend.compileModel` + and `morphir.backend.generateModel` for the tuple `morphir-ir` / + `morphir-json` / envelope `0.1.0-draft.1` / IR `4.0.0`; the model payload is + the IR v4 document that `compile` returns. It offers no Ion tuple, because the + released guest is WASM and the SDK has no Ion reader for WASM targets. Hosts + that offer only MEP `0.1`, or no model transport, get the `0.1` line + unchanged. The bundle's capability claims and `.github/extensions.toml` + declare both protocol versions. Released as `extension/rust/v0.4.0`. - SDK 0.4.0 adds canonical numeric/string `RequestId` wire identity and explicit constructors that preserve string IDs through native and generated guest dispatch, including lifecycle errors. Numeric convenience constructors retain diff --git a/Cargo.lock b/Cargo.lock index 80e1757c6..18c516aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3768,7 +3768,7 @@ dependencies = [ [[package]] name = "morphir-rust-binding" -version = "0.3.0" +version = "0.4.0" dependencies = [ "cucumber", "extism", @@ -3778,6 +3778,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", + "serde", "serde_json", "syn 3.0.6", "tempfile", diff --git a/README.md b/README.md index 5f57af6eb..372c42407 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ This table lists independently releasable extensions. Registrations come from | Extension | Package | Version | Description | | --- | --- | --- | --- | | `morphir-elm-native` | `morphir-elm-binding` | `0.2.0` | Compiles Elm type declarations to IR v3 and v4 and generates Elm back; incremental. | -| `morphir-rust` | `morphir-rust-binding` | `0.3.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4. | +| `morphir-rust` | `morphir-rust-binding` | `0.4.0` | Compiles and generates Rust types, conditional functions, pattern matches, calls and typed lambdas using IR v3 and v4; MEP 0.1, and MEP 0.2.0-draft.1 with model operations. | The Python extension (crate `morphir-python-binding`, bundle `morphir-python`) is now maintained and released from [finos/morphir-python](https://github.com/finos/morphir-python). diff --git a/crates/morphir-rust-binding/Cargo.toml b/crates/morphir-rust-binding/Cargo.toml index 30f52b476..ed36aeff0 100644 --- a/crates/morphir-rust-binding/Cargo.toml +++ b/crates/morphir-rust-binding/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "morphir-rust-binding" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true authors.workspace = true @@ -23,6 +23,7 @@ indexmap = "2" [dev-dependencies] tempfile.workspace = true +serde = "1" cucumber = "0.23" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/morphir-rust-binding/README.md b/crates/morphir-rust-binding/README.md index 91965a5d2..45cd6a65f 100644 --- a/crates/morphir-rust-binding/README.md +++ b/crates/morphir-rust-binding/README.md @@ -3,7 +3,8 @@ `morphir-rust-binding` implements the `morphir-rust` extension. It provides MEP `frontend/compile` and `backend/generate` natively and as an Extism WASM guest. Both capabilities advertise IR versions `3` and `4`, language/target `rust` and -source suffix `.rs`. Syn parses source without running rustc, macros or build +source suffix `.rs`. The extension speaks MEP `0.1` and MEP `0.2.0-draft.1`; on +the second line it also serves the [model operations](#model-operations-on-mep-020-draft1). Syn parses source without running rustc, macros or build scripts. The shared `morphir-core` codecs and typed migration handle the IR. The frontend and backend translate types and a small subset of pure @@ -322,6 +323,45 @@ their declared order. The consumer supplies external crates and implementations. Mappings do not synthesize opaque representations. Unknown types without a mapping, wrong arities, invalid names and Rust name collisions fail generation. +## Model operations on MEP 0.2.0-draft.1 + +The extension declares MEP `0.2.0-draft.1` and MEP `0.1`. It selects +`0.2.0-draft.1` only when the host offers that version with a model transport +offer. A host that offers only `0.1`, or no model transport, gets `0.1` and the +legacy `compile` and `generate` methods, unchanged. + +On `0.2.0-draft.1` the extension serves one tuple for each operation: + +| Operation | Profile | Codec | Envelope | IR | +| --- | --- | --- | --- | --- | +| `morphir.frontend.compileModel` | `morphir-ir` | `morphir-json` | `0.1.0-draft.1` | `4.0.0` | +| `morphir.backend.generateModel` | `morphir-ir` | `morphir-json` | `0.1.0-draft.1` | `4.0.0` | + +It offers no Ion tuple: the released guest is WASM, and the SDK has no Ion reader +for WASM targets. It offers no IR 3 tuple; IR 3 remains available on MEP `0.1`. +The legacy `compile` and `generate` methods also stay available on +`0.2.0-draft.1`. + +`compileModel` compiles with the same rules as `compile` at IR v4: one document, +no dependencies. A request with dependencies fails with `RS_DEPENDENCIES`. The +model payload is the IR v4 JSON document that `compile` returns for the same +source. `modelId` is the package name and `revision` is `sha256:` followed by the +payload digest. A source that the frontend rejects gives `success: false`, the +diagnostics, and no model. + +`generateModel` admits the envelope against its tuple, checks the payload's +length and SHA-256, and generates the same `lib.rs` as `generate` does for the +payload's IR, for target `rust`. A request with a required guarantee generates +nothing; the extension checks no guarantee. + +The SDK session refuses, with a JSON-RPC error and before the extension runs, a +tuple that the session did not agree, an envelope that does not match its tuple +and a payload that fails its integrity checks. The guest's limits are 64 MiB +encoded, 48 MiB decoded, depth 128 and 16 Mi nodes; a host takes the smaller +value of each. Depth 128 is the deepest document that the JSON decoder reads, so +every admitted payload can be decoded. [The model operation tests](tests/model_operations.rs) compare +both operations with golden files in `tests/fixtures/model/`. + ## Native usage and tests The public entry point is `RustExtension`, implementing the SDK's `Extension`, @@ -344,7 +384,7 @@ For a distributable guest, build with `--release`. Its artifact is `MORPHIR_RUST_GUEST` to that artifact's absolute path when running the host tests. Tests load it with WASI disabled and compare native and guest results for both IR versions. -The guest reports ID `morphir-rust`, name `Morphir Rust`, version `0.3.0`, +The guest reports ID `morphir-rust`, name `Morphir Rust`, version `0.4.0`, frontend language `rust`, suffix `.rs`, backend target `rust`, and `irVersions: ["3", "4"]` on both capabilities. @@ -367,9 +407,9 @@ artifact, its SHA-256 checksum and `release.json`. A clean checkout builds from an archived HEAD and records that commit in the descriptor. A dirty checkout can produce a local test bundle, without release provenance. -Independent releases use tags such as `extension/rust/v0.3.0`. Download the -assets from the [Rust v0.3.0 release](https://github.com/finos/morphir-rust/releases/tag/extension/rust/v0.3.0). -The published descriptor is named `morphir-rust-binding-0.3.0.release.json`; +Independent releases use tags such as `extension/rust/v0.4.0`. Download the +assets from the [Rust v0.4.0 release](https://github.com/finos/morphir-rust/releases/tag/extension/rust/v0.4.0). +The published descriptor is named `morphir-rust-binding-0.4.0.release.json`; rename it to `release.json` alongside the WASM and checksum before publishing the directory to a local extension repository. @@ -406,7 +446,9 @@ read from the exact WASM being packaged and checked against later; older CLIs cannot read this descriptor. The versioned WASM and `.wasm.sha256` names are unchanged. The downloaded descriptor is still named `-.release.json`; rename it to `release.json` before -publishing. WASM installs show `Claims: unchecked`. +publishing. WASM installs show `Claims: unchecked`. The claims list MEP `0.1` +and `0.2.0-draft.1` and, under `capabilities.modelTransport`, the two model +tuples and the guest's limits. See the [packaging contract](../../docs/extensions/capability-claims.md#packaging-wasm-extensions) for the descriptor shape and provenance rules. diff --git a/crates/morphir-rust-binding/src/lib.rs b/crates/morphir-rust-binding/src/lib.rs index d20749538..e3c483329 100644 --- a/crates/morphir-rust-binding/src/lib.rs +++ b/crates/morphir-rust-binding/src/lib.rs @@ -1,6 +1,10 @@ //! Rust type and pure-function frontend and backend for Morphir IR v3 and v4. //! Explicit native and external function bindings are extracted into v4 value definitions. //! +//! The extension speaks MEP `0.1` and MEP `0.2.0-draft.1`. On the second line it also serves the +//! model operations `morphir.frontend.compileModel` and `morphir.backend.generateModel` for the +//! tuple `morphir-ir` / `morphir-json` / envelope `0.1.0-draft.1` / IR `4.0.0`. +//! //! ``` //! use morphir_extension_sdk::{Extension, NativeExtension}; //! use morphir_rust_binding::RustExtension; @@ -11,6 +15,7 @@ mod backend; mod frontend; mod functions; +mod model_transport; mod patterns; mod values; @@ -52,6 +57,7 @@ impl Extension for RustExtension { ir_versions: vec!["3".into(), "4".into()], generate: true, }), + model_transport: Some(model_transport::support()), ..Default::default() } } @@ -72,6 +78,13 @@ impl Frontend for RustExtension { ) } + fn compile_model( + &self, + request: morphir_extension_sdk::model::CompileModelRequest, + ) -> Result { + model_transport::compile_model(request) + } + fn supported_languages() -> Vec { vec!["rust".into()] } @@ -92,6 +105,13 @@ impl Backend for RustExtension { }) } + fn generate_model( + &self, + request: morphir_extension_sdk::model::GenerateModelRequest, + ) -> Result { + model_transport::generate_model(request) + } + fn target_languages() -> Vec { vec!["rust".into()] } diff --git a/crates/morphir-rust-binding/src/model_transport.rs b/crates/morphir-rust-binding/src/model_transport.rs new file mode 100644 index 000000000..e941f771d --- /dev/null +++ b/crates/morphir-rust-binding/src/model_transport.rs @@ -0,0 +1,232 @@ +//! MEP `0.2.0-draft.1` model operations for one tuple per operation: profile `morphir-ir`, codec +//! `morphir-json`, envelope `0.1.0-draft.1` and IR `4.0.0`. +//! +//! Both operations reuse the MEP `0.1` compile and generate paths. The model payload is the IR v4 +//! JSON document that `morphir.frontend.compile` returns. The SDK session checks tuples, limits and +//! payload integrity before and after each call; this module checks the tuple, the IR version +//! option and the envelope again because the trait methods can also be called without a session. +//! +//! The extension offers no Ion tuple: the released guest is WASM, and the SDK has no Ion reader for +//! WASM targets. + +use crate::RustExtension; +use morphir_extension_sdk::model::{ + CompileModelRequest, CompileModelResult, GenerateModelRequest, MODEL_ENVELOPE_VERSION, + ModelCodec, ModelOperation, ModelPayload, ModelTransportLimits, ModelTransportSupport, + ModelTransportTuple, MorphirModel, retain_model, +}; +use morphir_extension_sdk::prelude::*; + +/// The model-content profile that this extension reads and writes. +const PROFILE: &str = "morphir-ir"; +/// The semantic IR version of the model payload. +const IR_VERSION: &str = "4.0.0"; +/// The IR version option of the MEP `0.1` compile path that produces the payload. +const LEGACY_IR_VERSION: &str = "4"; + +/// The complete tuple of one operation. +fn tuple(operation: ModelOperation) -> ModelTransportTuple { + ModelTransportTuple { + operation, + profile: PROFILE.into(), + codec: ModelCodec::MorphirJson, + envelope_version: MODEL_ENVELOPE_VERSION.into(), + ir_version: IR_VERSION.into(), + } +} + +/// The deepest document that `serde_json` decodes with its default recursion limit, counted as +/// model admission counts depth (the root at depth one). +const MAX_DEPTH: u32 = 128; + +/// The guest's own upper bounds. A host takes the smaller value of each limit. The depth bound is +/// the decoder's, so every payload that admission accepts can be decoded. +fn limits() -> ModelTransportLimits { + ModelTransportLimits { + max_encoded_bytes: 64 * 1024 * 1024, + max_decoded_bytes: 48 * 1024 * 1024, + max_depth: MAX_DEPTH, + max_nodes: 16 * 1024 * 1024, + } +} + +/// The model transport that the extension advertises. +pub(crate) fn support() -> ModelTransportSupport { + ModelTransportSupport { + tuples: vec![ + tuple(ModelOperation::CompileModel), + tuple(ModelOperation::GenerateModel), + ], + limits: limits(), + guarantees: vec![], + } +} + +/// Compile one Rust document into a Morphir IR v4 model envelope. +pub(crate) fn compile_model(request: CompileModelRequest) -> Result { + if request.output != tuple(ModelOperation::CompileModel) { + return Err(ExtensionError::InvalidParams( + "Unsupported model output tuple".into(), + )); + } + // The session refuses this too: the IR version option must name the tuple's version. + if request.options.ir_version != request.output.ir_version { + return Err(ExtensionError::InvalidParams( + "The irVersion option differs from the model output tuple".into(), + )); + } + // The frontend does not compile against dependencies, so their models are never decoded. + if !request.dependencies.is_empty() { + return Ok(CompileModelResult::Failed { + diagnostics: vec![crate::error( + "RS_DEPENDENCIES", + "External dependencies are not supported", + )], + }); + } + let package_name = request.package.name.clone(); + let mut options = request.options; + options.ir_version = LEGACY_IR_VERSION.into(); + let result = RustExtension.compile(CompileRequest { + language_id: request.language_id, + sources: request.sources, + package: request.package, + dependencies: vec![], + options, + baseline: None, + })?; + let Some(ir) = result.ir.filter(|_| result.success) else { + return Ok(CompileModelResult::Failed { + diagnostics: result.diagnostics, + }); + }; + Ok(CompileModelResult::Compiled { + model: Box::new(encode(&package_name, &ir)?), + diagnostics: result.diagnostics, + modules: result.modules, + }) +} + +/// Generate Rust from a Morphir IR v4 model envelope. +pub(crate) fn generate_model(request: GenerateModelRequest) -> Result { + let input = tuple(ModelOperation::GenerateModel); + if request.input != input { + return Err(ExtensionError::InvalidParams( + "Unsupported model input tuple".into(), + )); + } + if let Some(required) = request.required_guarantees.first() { + return Ok(GenerateResult { + success: false, + artifacts: vec![], + diagnostics: vec![crate::error( + "RS_UNSUPPORTED_GUARANTEE", + format!( + "The Rust backend cannot check guarantee '{}'", + required.guarantee + ), + )], + }); + } + // Admission checks the envelope against the tuple, the payload's length and SHA-256, and the + // limits, before the bytes are read as IR. + let retained = retain_model(&request.model, &input, &limits()) + .map_err(|error| ExtensionError::InvalidParams(format!("Model not admitted: {error}")))?; + let ir = serde_json::from_slice(retained.bytes())?; + RustExtension.generate(GenerateRequest { + ir, + target: request.target, + options: request.options, + }) +} + +fn encode(package_name: &str, ir: &serde_json::Value) -> Result { + let bytes = serde_json::to_vec(ir)?; + let payload = ModelPayload::from_bytes(ModelCodec::MorphirJson, &bytes) + .map_err(|error| ExtensionError::ExecutionFailed(format!("Model payload: {error}")))?; + Ok(MorphirModel { + envelope_version: MODEL_ENVELOPE_VERSION.into(), + model_id: package_name.into(), + revision: format!("sha256:{}", payload.sha256), + ir_version: IR_VERSION.into(), + profile: PROFILE.into(), + payload, + evidence: vec![], + critical: vec![], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_advertised_support_is_valid_for_this_target() { + let support = support(); + let agreed = + morphir_extension_sdk::model::NegotiatedModelTransport::select(&support, &support) + .unwrap(); + assert_eq!(agreed.support(), &support); + assert!( + support + .tuples + .iter() + .all(|tuple| tuple.codec == ModelCodec::MorphirJson) + ); + } + + fn nested(depth: u32) -> String { + let levels = depth as usize - 1; + format!("{}0{}", "[".repeat(levels), "]".repeat(levels)) + } + + fn admit(text: &str, max_depth: u32) -> std::result::Result<(), &'static str> { + let model = MorphirModel { + payload: ModelPayload::from_bytes(ModelCodec::MorphirJson, text.as_bytes()).unwrap(), + ..encode("acme/example", &serde_json::json!({})).unwrap() + }; + let limits = ModelTransportLimits { + max_depth, + ..limits() + }; + retain_model(&model, &tuple(ModelOperation::GenerateModel), &limits) + .map(|_| ()) + .map_err(|error| error.code()) + } + + #[test] + fn every_depth_that_admission_accepts_is_decodable() { + // Admission refuses the deeper document for its depth, and refuses the deepest allowed + // one only for its profile, so it accepts that depth. The decoder reads exactly that depth. + assert_eq!(admit(&nested(MAX_DEPTH + 1), MAX_DEPTH), Err("depth_limit")); + assert_ne!(admit(&nested(MAX_DEPTH), MAX_DEPTH), Err("depth_limit")); + assert!(serde_json::from_str::(&nested(MAX_DEPTH)).is_ok()); + assert!(serde_json::from_str::(&nested(MAX_DEPTH + 1)).is_err()); + assert_eq!(support().limits.max_depth, MAX_DEPTH); + } + + #[test] + fn dependencies_fail_compilation_without_decoding_their_models() { + let request: CompileModelRequest = serde_json::from_value(serde_json::json!({ + "languageId": "rust", + "sources": {"documents": [ + {"uri": "models.rs", "languageId": "rust", "version": 1, "text": "pub type A = i64;"} + ]}, + "package": {"name": "acme/example"}, + "dependencies": [{"packageName": "acme/other", "model": { + "envelopeVersion": "0.1.0-draft.1", "modelId": "acme/other", "revision": "r", + "irVersion": "4.0.0", "profile": "morphir-ir", + "payload": {"codec": "morphir-json", "layout": "single-document", + "decodedByteLength": 1, "sha256": "x", "content": "not base64"} + }}], + "options": {"irVersion": "4.0.0", "typesOnly": false}, + "contextDigest": "0".repeat(64), + "output": tuple(ModelOperation::CompileModel), + })) + .unwrap(); + let CompileModelResult::Failed { diagnostics } = compile_model(request).unwrap() else { + panic!("dependencies must fail compilation"); + }; + assert_eq!(diagnostics[0].code.as_deref(), Some("RS_DEPENDENCIES")); + } +} diff --git a/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json b/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json new file mode 100644 index 000000000..78f1f4ab0 --- /dev/null +++ b/crates/morphir-rust-binding/tests/fixtures/model/compiled-ir-v4.json @@ -0,0 +1,508 @@ +{ + "formatVersion": 4, + "distribution": { + "Library": { + "packageName": "acme/example", + "dependencies": {}, + "def": { + "modules": { + "models": { + "Public": { + "types": { + "person": { + "Public": { + "doc": "A person in the domain.", + "TypeAliasDefinition": { + "typeParams": [], + "typeExp": { + "Record": { + "fields": { + "name": { + "Reference": { + "fqname": "morphir/SDK:string#string" + } + }, + "age": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + } + } + } + } + } + }, + "decision": { + "Public": { + "doc": "The state of a decision.", + "CustomTypeDefinition": { + "typeParams": [], + "access": "Public", + "constructors": { + "pending": [], + "accepted": [ + [ + "field-0", + { + "Reference": { + "fqname": "acme/example:models#person" + } + } + ] + ], + "rejected": [ + [ + "reason", + { + "Reference": { + "fqname": "morphir/SDK:string#string" + } + } + ] + ] + } + } + } + }, + "score": { + "Public": { + "doc": "", + "TypeAliasDefinition": { + "typeParams": [], + "typeExp": { + "Tuple": { + "elements": [ + { + "Reference": { + "fqname": "morphir/SDK:string#string" + } + }, + { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + ] + } + } + } + } + } + }, + "values": { + "select": { + "Public": { + "doc": "The larger of two values, or zero when they are equal.", + "ExpressionBody": { + "inputTypes": { + "a": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "b": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "outputType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "body": { + "IfThenElse": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "condition": { + "Apply": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + }, + "function": { + "Apply": { + "attributes": { + "inferredType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + } + } + }, + "function": { + "Reference": { + "attributes": { + "inferredType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + } + } + } + } + }, + "fqname": "morphir/SDK:basics#equal" + } + }, + "argument": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "a" + } + } + } + }, + "argument": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "b" + } + } + } + }, + "then": { + "Literal": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "literal": { + "IntegerLiteral": 0 + } + } + }, + "else": { + "IfThenElse": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "condition": { + "Apply": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + }, + "function": { + "Apply": { + "attributes": { + "inferredType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + } + } + }, + "function": { + "Reference": { + "attributes": { + "inferredType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Function": { + "parameterType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "returnType": { + "Reference": { + "fqname": "morphir/SDK:basics#bool" + } + } + } + } + } + } + }, + "fqname": "morphir/SDK:basics#greater-than" + } + }, + "argument": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "a" + } + } + } + }, + "argument": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "b" + } + } + } + }, + "then": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "a" + } + }, + "else": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "b" + } + } + } + } + } + } + } + } + }, + "amount": { + "Public": { + "doc": "", + "ExpressionBody": { + "inputTypes": { + "value": { + "Reference": { + "fqname": "morphir/SDK:maybe#maybe", + "args": [ + { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + ] + } + } + }, + "outputType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + }, + "body": { + "PatternMatch": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "value": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:maybe#maybe", + "args": [ + { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + ] + } + } + }, + "name": "value" + } + }, + "cases": [ + { + "pattern": { + "ConstructorPattern": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:maybe#maybe", + "args": [ + { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + ] + } + } + }, + "fqname": "morphir/SDK:maybe#just", + "patterns": [ + { + "AsPattern": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "pattern": { + "WildcardPattern": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + } + } + }, + "name": "morphir-local-0" + } + } + ] + } + }, + "body": { + "Variable": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "name": "morphir-local-0" + } + } + }, + { + "pattern": { + "ConstructorPattern": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:maybe#maybe", + "args": [ + { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + ] + } + } + }, + "fqname": "morphir/SDK:maybe#nothing", + "patterns": [] + } + }, + "body": { + "Literal": { + "attributes": { + "inferredType": { + "Reference": { + "fqname": "morphir/SDK:basics#int" + } + } + }, + "literal": { + "IntegerLiteral": 0 + } + } + } + } + ] + } + } + } + } + } + }, + "doc": "Golden source for the MEP 0.2.0-draft.1 model operation tests." + } + } + } + } + } + } +} diff --git a/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs b/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs new file mode 100644 index 000000000..761fb3a2f --- /dev/null +++ b/crates/morphir-rust-binding/tests/fixtures/model/generated-lib.rs @@ -0,0 +1,24 @@ +pub mod models { + ///The state of a decision. + pub enum Decision { + Pending, + Accepted(crate::models::Person), + Rejected(::std::string::String), + } + ///A person in the domain. + pub struct Person { + pub name: ::std::string::String, + pub age: i64, + } + pub type Score = (::std::string::String, i64); + ///The larger of two values, or zero when they are equal. + pub fn select(a: i64, b: i64) -> i64 { + if (a) == (b) { 0i64 } else { if (a) > (b) { a } else { b } } + } + pub fn amount(value: ::std::option::Option) -> i64 { + match value { + ::std::option::Option::Some(__morphir_binding_1) => __morphir_binding_1, + ::std::option::Option::None => 0i64, + } + } +} diff --git a/crates/morphir-rust-binding/tests/fixtures/model/models.rs b/crates/morphir-rust-binding/tests/fixtures/model/models.rs new file mode 100644 index 000000000..d78017da8 --- /dev/null +++ b/crates/morphir-rust-binding/tests/fixtures/model/models.rs @@ -0,0 +1,28 @@ +//! Golden source for the MEP 0.2.0-draft.1 model operation tests. + +/// A person in the domain. +pub struct Person { + pub name: String, + pub age: i64, +} + +/// The state of a decision. +pub enum Decision { + Pending, + Accepted(Person), + Rejected { reason: String }, +} + +pub type Score = (String, i64); + +/// The larger of two values, or zero when they are equal. +pub fn select(a: i64, b: i64) -> i64 { + if a == b { 0 } else if a > b { a } else { b } +} + +pub fn amount(value: Option) -> i64 { + match value { + Some(amount) => amount, + None => 0, + } +} diff --git a/crates/morphir-rust-binding/tests/model_operations.rs b/crates/morphir-rust-binding/tests/model_operations.rs new file mode 100644 index 000000000..875bb69e9 --- /dev/null +++ b/crates/morphir-rust-binding/tests/model_operations.rs @@ -0,0 +1,323 @@ +//! MEP `0.2.0-draft.1` negotiation and model operations through native JSON-RPC dispatch. + +#[path = "support/model.rs"] +mod model; + +use model::*; +use morphir_extension_sdk::{ + model::{ + CompileModelResult, MODEL_ENVELOPE_VERSION, MODEL_MEP_VERSION, ModelCodec, + ModelGuaranteeRequirement, ModelOperation, ModelTransportSupport, MorphirModel, + }, + prelude::*, + protocol::{DescribeParams, methods}, +}; +use morphir_rust_binding::RustExtension; +use serde_json::{Value, json}; + +fn compiled_model(session: &Session) -> MorphirModel { + let response = session.call(methods::COMPILE_MODEL, a_compile_model_request()); + assert!(response.error.is_none(), "{:?}", response.error); + match serde_json::from_value(response.result.unwrap()).unwrap() { + CompileModelResult::Compiled { model, .. } => *model, + CompileModelResult::Failed { diagnostics } => panic!("compile failed: {diagnostics:?}"), + } +} + +fn agreed(session: &Session) -> ModelTransportSupport { + serde_json::from_value(session.result()["capabilities"]["modelTransport"].clone()).unwrap() +} + +// ---- negotiation ------------------------------------------------------------------------------ + +#[test] +fn describe_claims_both_protocol_lines_and_exactly_the_json_tuples() { + let session = Session::start(&[], None); + let response = session.call( + methods::DESCRIBE, + DescribeParams { + protocol_versions: vec!["0.1".into()], + }, + ); + let claims = response.result.expect("describe result"); + let mut versions: Vec = + serde_json::from_value(claims["protocolVersions"].clone()).unwrap(); + versions.sort(); + assert_eq!(versions, ["0.1", MODEL_MEP_VERSION]); + let support: ModelTransportSupport = + serde_json::from_value(claims["capabilities"]["modelTransport"].clone()).unwrap(); + assert_eq!( + support.tuples, + [ + a_json_tuple(ModelOperation::CompileModel), + a_json_tuple(ModelOperation::GenerateModel) + ] + ); + assert!(support.guarantees.is_empty()); +} + +#[test] +fn a_host_that_offers_only_mep_0_1_gets_0_1_without_model_transport() { + let session = Session::on_0_1(); + assert_eq!(session.result()["protocolVersion"], "0.1"); + assert!( + session.result()["capabilities"] + .get("modelTransport") + .is_none(), + "{:#}", + session.result() + ); +} + +#[test] +fn a_model_host_gets_the_agreed_json_tuples_in_host_order_and_its_smaller_limits() { + let session = Session::on_model_line(); + assert_eq!(session.result()["protocolVersion"], MODEL_MEP_VERSION); + let support = agreed(&session); + assert_eq!( + support.tuples, + [ + a_json_tuple(ModelOperation::CompileModel), + a_json_tuple(ModelOperation::GenerateModel) + ] + ); + assert_eq!(support.limits, moonbit_host_limits()); +} + +#[test] +fn the_agreement_follows_the_host_order_and_takes_each_smaller_limit() { + // The host lists generate before compile, and offers more nodes than the guest allows. + let mut offer = a_moonbit_like_offer(); + offer.tuples.reverse(); + offer.limits.max_nodes = u32::try_from(i32::MAX).unwrap(); + let session = Session::start(&[MODEL_MEP_VERSION, "0.1"], Some(offer)); + let support = agreed(&session); + assert_eq!( + support.tuples, + [ + a_json_tuple(ModelOperation::GenerateModel), + a_json_tuple(ModelOperation::CompileModel) + ] + ); + assert_eq!(support.limits.max_nodes, 16 * 1024 * 1024); + assert_eq!( + support.limits.max_decoded_bytes, + moonbit_host_limits().max_decoded_bytes + ); +} + +#[test] +fn a_draft_host_without_a_model_transport_offer_gets_0_1() { + let session = Session::start(&[MODEL_MEP_VERSION, "0.1"], None); + assert!(session.initialized.error.is_none()); + assert_eq!(session.result()["protocolVersion"], "0.1"); +} + +#[test] +fn an_ion_only_offer_agrees_on_no_tuple_and_no_model_operation() { + let session = Session::start(&[MODEL_MEP_VERSION, "0.1"], Some(an_ion_only_offer())); + assert!(session.initialized.error.is_none()); + assert!(agreed(&session).tuples.is_empty()); + let response = session.call(methods::COMPILE_MODEL, a_compile_model_request()); + assert!(response.result.is_none() && response.error.is_some()); +} + +// ---- compileModel and generateModel ----------------------------------------------------------- + +#[test] +fn compile_model_returns_the_golden_ir_v4_envelope() { + let session = Session::on_model_line(); + let response = session.call(methods::COMPILE_MODEL, a_compile_model_request()); + let result = response.result.expect("compileModel result"); + assert_eq!(result["success"], true); + assert_eq!(result["modules"], json!(["Models"])); + let model: MorphirModel = serde_json::from_value(result["model"].clone()).unwrap(); + assert_eq!(model.envelope_version, MODEL_ENVELOPE_VERSION); + assert_eq!(model.profile, "morphir-ir"); + assert_eq!(model.ir_version, "4.0.0"); + assert_eq!(model.model_id, "acme/example"); + assert_eq!(model.revision, format!("sha256:{}", model.payload.sha256)); + assert!(model.evidence.is_empty() && model.critical.is_empty()); + let payload = payload_json(&model, ModelOperation::CompileModel); + assert_golden( + "compiled-ir-v4.json", + &format!("{}\n", serde_json::to_string_pretty(&payload).unwrap()), + ); +} + +#[test] +fn compile_model_payload_is_the_legacy_ir_v4_result() { + let session = Session::on_model_line(); + let payload = payload_json(&compiled_model(&session), ModelOperation::CompileModel); + let legacy = RustExtension.compile(a_legacy_compile_request()).unwrap(); + assert!(legacy.success, "{:?}", legacy.diagnostics); + assert_eq!(legacy.ir_version.as_deref(), Some("4")); + assert_eq!(payload, legacy.ir.unwrap()); +} + +#[test] +fn generate_model_from_the_compiled_envelope_returns_the_golden_rust() { + let session = Session::on_model_line(); + let model = compiled_model(&session); + let response = session.call(methods::GENERATE_MODEL, a_generate_model_request(model)); + assert!(response.error.is_none(), "{:?}", response.error); + let generated: GenerateResult = serde_json::from_value(response.result.unwrap()).unwrap(); + assert!(generated.success, "{:?}", generated.diagnostics); + assert_eq!(generated.artifacts.len(), 1); + assert_eq!(generated.artifacts[0].path, "lib.rs"); + assert_golden("generated-lib.rs", &generated.artifacts[0].content); +} + +#[test] +fn generate_model_returns_the_legacy_generate_result() { + let session = Session::on_model_line(); + let model = compiled_model(&session); + let ir = payload_json(&model, ModelOperation::CompileModel); + let response = session.call(methods::GENERATE_MODEL, a_generate_model_request(model)); + let rich: Value = response.result.unwrap(); + let legacy = RustExtension + .generate(GenerateRequest { + ir, + target: "rust".into(), + options: Default::default(), + }) + .unwrap(); + assert_eq!(rich, serde_json::to_value(legacy).unwrap()); +} + +#[test] +fn compile_model_failure_has_diagnostics_and_no_model() { + let session = Session::on_model_line(); + let request = a_compile_model_request_for("models.rs", "pub struct Broken { pub field: &str }"); + let response = session.call(methods::COMPILE_MODEL, request); + let result = response.result.expect("a failed compilation is a result"); + assert_eq!(result["success"], false); + assert!(result.get("model").is_none(), "{result:#}"); + assert!(!result["diagnostics"].as_array().unwrap().is_empty()); +} + +#[test] +fn compile_model_takes_the_module_from_a_relative_directory_uri() { + let session = Session::on_model_line(); + let request = a_compile_model_request_for("src/domain/models.rs", "pub type Id = String;"); + let response = session.call(methods::COMPILE_MODEL, request); + let result = response.result.expect("compileModel result"); + assert_eq!(result["success"], true, "{result:#}"); + assert_eq!(result["modules"], json!(["Models"])); +} + +// ---- refusals --------------------------------------------------------------------------------- + +fn assert_refused(response: morphir_extension_sdk::protocol::ExtensionResponse) { + assert!(response.result.is_none(), "{:?}", response.result); + assert!(response.error.is_some()); +} + +#[test] +fn generate_model_refuses_an_unagreed_ion_tuple() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.input = an_ion_tuple(ModelOperation::GenerateModel); + assert_refused(session.call(methods::GENERATE_MODEL, request)); +} + +#[test] +fn generate_model_refuses_an_unagreed_ir_version() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.input.ir_version = "3.0.0".into(); + request.model.ir_version = "3.0.0".into(); + assert_refused(session.call(methods::GENERATE_MODEL, request)); +} + +#[test] +fn compile_model_refuses_an_unagreed_output_tuple() { + let session = Session::on_model_line(); + let mut request = a_compile_model_request(); + request.output = an_ion_tuple(ModelOperation::CompileModel); + assert_refused(session.call(methods::COMPILE_MODEL, request)); +} + +#[test] +fn generate_model_refuses_another_envelope_version() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.model.envelope_version = "0.1.0-draft.2".into(); + assert_refused(session.call(methods::GENERATE_MODEL, request)); +} + +#[test] +fn generate_model_refuses_a_payload_that_fails_its_digest() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.model.payload.sha256 = "0".repeat(64); + assert_refused(session.call(methods::GENERATE_MODEL, request)); +} + +#[test] +fn model_operations_are_refused_on_the_0_1_line() { + let session = Session::on_0_1(); + assert_refused(session.call(methods::COMPILE_MODEL, a_compile_model_request())); +} + +#[test] +fn generate_model_refuses_a_required_guarantee() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.required_guarantees = vec![ModelGuaranteeRequirement { + guarantee: "morphir.typed".into(), + scope: "acme/example".into(), + }]; + let response = session.call(methods::GENERATE_MODEL, request); + let generated = response + .result + .map(|result| serde_json::from_value::(result).unwrap()); + assert!( + generated.is_none_or(|result| !result.success && result.artifacts.is_empty()), + "a required guarantee must not generate artifacts" + ); +} + +// ---- the trait methods without a session ------------------------------------------------------ + +#[test] +fn the_trait_methods_refuse_an_unagreed_tuple_without_a_session() { + let mut compile = a_compile_model_request(); + compile.output = an_ion_tuple(ModelOperation::CompileModel); + assert!(RustExtension.compile_model(compile).is_err()); + + let session = Session::on_model_line(); + let model = compiled_model(&session); + let mut generate = a_generate_model_request(model.clone()); + generate.input = an_ion_tuple(ModelOperation::GenerateModel); + generate.model.profile = "morphir-model".into(); + generate.model.payload.codec = ModelCodec::IonBinary; + assert!(RustExtension.generate_model(generate).is_err()); + + let mut generate = a_generate_model_request(model); + generate.model.profile = "morphir-model".into(); + assert!(RustExtension.generate_model(generate).is_err()); +} + +#[test] +fn compile_model_refuses_an_ir_version_option_that_differs_from_the_tuple_without_a_session() { + let mut request = a_compile_model_request(); + request.options.ir_version = "3".into(); + assert!(RustExtension.compile_model(request).is_err()); +} + +#[test] +fn the_trait_method_refuses_a_required_guarantee_without_a_session() { + let session = Session::on_model_line(); + let mut request = a_generate_model_request(compiled_model(&session)); + request.required_guarantees = vec![ModelGuaranteeRequirement { + guarantee: "morphir.typed".into(), + scope: "acme/example".into(), + }]; + let result = RustExtension.generate_model(request).unwrap(); + assert!(!result.success && result.artifacts.is_empty()); + assert_eq!( + result.diagnostics[0].code.as_deref(), + Some("RS_UNSUPPORTED_GUARANTEE") + ); +} diff --git a/crates/morphir-rust-binding/tests/support/model.rs b/crates/morphir-rust-binding/tests/support/model.rs new file mode 100644 index 000000000..588f0a8ab --- /dev/null +++ b/crates/morphir-rust-binding/tests/support/model.rs @@ -0,0 +1,236 @@ +//! Object mothers for MEP `0.2.0-draft.1` model operation tests. + +#![allow(dead_code)] + +use morphir_extension_sdk::{ + NativeExtension, + model::{ + CompileModelRequest, GenerateModelRequest, MODEL_ENVELOPE_VERSION, MODEL_MEP_VERSION, + ModelCodec, ModelOperation, ModelTransportLimits, ModelTransportSupport, + ModelTransportTuple, MorphirModel, + }, + prelude::*, + protocol::{ExtensionRequest, ExtensionResponse, InitializeParams, PeerInfo, methods}, +}; +use morphir_rust_binding::RustExtension; +use serde_json::Value; +use std::path::PathBuf; + +/// The source compiled by the golden tests. +pub const SOURCE: &str = include_str!("../fixtures/model/models.rs"); + +/// The directory of the recorded golden outputs. +pub fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/model") +} + +/// Compare `actual` with a recorded golden file. Set `MORPHIR_BLESS=1` to record it again. +pub fn assert_golden(name: &str, actual: &str) { + let path = fixtures().join(name); + if std::env::var_os("MORPHIR_BLESS").is_some() { + std::fs::write(&path, actual).unwrap(); + } + let expected = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("cannot read golden {}: {error}", path.display())); + assert_eq!(expected, actual, "golden {name} differs"); +} + +/// The tuple of `operation` that the extension advertises. +pub fn a_json_tuple(operation: ModelOperation) -> ModelTransportTuple { + ModelTransportTuple { + operation, + profile: "morphir-ir".into(), + codec: ModelCodec::MorphirJson, + envelope_version: MODEL_ENVELOPE_VERSION.into(), + ir_version: "4.0.0".into(), + } +} + +/// The Ion tuple of `operation`, which the extension does not advertise. +pub fn an_ion_tuple(operation: ModelOperation) -> ModelTransportTuple { + ModelTransportTuple { + profile: "morphir-model".into(), + codec: ModelCodec::IonBinary, + ..a_json_tuple(operation) + } +} + +/// The limits that the MoonBit CLI worker host offers. +pub fn moonbit_host_limits() -> ModelTransportLimits { + ModelTransportLimits { + max_encoded_bytes: 2_796_204, + max_decoded_bytes: 2 * 1024 * 1024, + max_depth: 128, + max_nodes: 100_000, + } +} + +/// A host offer like the MoonBit CLI's: Ion first, then Morphir JSON, for both operations. +pub fn a_moonbit_like_offer() -> ModelTransportSupport { + ModelTransportSupport { + tuples: vec![ + an_ion_tuple(ModelOperation::CompileModel), + an_ion_tuple(ModelOperation::GenerateModel), + a_json_tuple(ModelOperation::CompileModel), + a_json_tuple(ModelOperation::GenerateModel), + ], + limits: moonbit_host_limits(), + guarantees: vec![], + } +} + +/// A host offer with only Ion tuples. +pub fn an_ion_only_offer() -> ModelTransportSupport { + ModelTransportSupport { + tuples: vec![ + an_ion_tuple(ModelOperation::CompileModel), + an_ion_tuple(ModelOperation::GenerateModel), + ], + ..a_moonbit_like_offer() + } +} + +/// A native extension and the result of its `morphir.initialize`. +pub struct Session { + pub extension: NativeExtension, + pub initialized: ExtensionResponse, + next_id: std::cell::Cell, +} + +impl Session { + /// Initialize a native Rust extension with `versions` and an optional model transport `offer`. + pub fn start(versions: &[&str], offer: Option) -> Self { + let extension = NativeExtension::frontend_backend(RustExtension).unwrap(); + let initialized = extension.protocol().handle( + ExtensionRequest::new( + methods::INITIALIZE, + InitializeParams { + model_transport: offer, + protocol_versions: versions.iter().map(|v| (*v).into()).collect(), + host: PeerInfo { + kind: Default::default(), + name: "model-test".into(), + version: "1.0.0".into(), + }, + }, + 0, + ) + .unwrap(), + ); + Self { + extension, + initialized, + next_id: std::cell::Cell::new(1), + } + } + + /// A session on MEP `0.1`. + pub fn on_0_1() -> Self { + let session = Self::start(&["0.1"], None); + assert!( + session.initialized.error.is_none(), + "{:?}", + session.initialized.error + ); + session + } + + /// A session on MEP `0.2.0-draft.1` with the MoonBit-like offer. + pub fn on_model_line() -> Self { + let session = Self::start(&[MODEL_MEP_VERSION, "0.1"], Some(a_moonbit_like_offer())); + assert!( + session.initialized.error.is_none(), + "{:?}", + session.initialized.error + ); + session + } + + /// The initialize result as JSON. + pub fn result(&self) -> &Value { + self.initialized.result.as_ref().expect("initialize result") + } + + /// Send one request and return its response. + pub fn call(&self, method: &str, params: impl serde::Serialize) -> ExtensionResponse { + let id = self.next_id.get(); + self.next_id.set(id + 1); + self.extension + .protocol() + .handle(ExtensionRequest::new(method, params, id).unwrap()) + } +} + +/// A `compileModel` request for the golden source. +pub fn a_compile_model_request() -> CompileModelRequest { + a_compile_model_request_for("models.rs", SOURCE) +} + +/// A `compileModel` request for one document. +pub fn a_compile_model_request_for(uri: &str, text: &str) -> CompileModelRequest { + CompileModelRequest { + language_id: "rust".into(), + sources: SourceSet { + root: None, + documents: vec![SourceDocument { + uri: uri.into(), + language_id: "rust".into(), + version: 1, + text: text.into(), + }], + }, + package: CompilePackage { + name: "acme/example".into(), + exposed_modules: None, + }, + dependencies: vec![], + options: CompileOptions { + types_only: false, + ir_version: "4.0.0".into(), + ..Default::default() + }, + context_digest: "0".repeat(64), + output: a_json_tuple(ModelOperation::CompileModel), + } +} + +/// The legacy `compile` request for the same source at IR v4. +pub fn a_legacy_compile_request() -> CompileRequest { + CompileRequest { + language_id: "rust".into(), + sources: a_compile_model_request().sources, + package: CompilePackage { + name: "acme/example".into(), + exposed_modules: None, + }, + options: CompileOptions { + types_only: false, + ir_version: "4".into(), + ..Default::default() + }, + ..Default::default() + } +} + +/// A `generateModel` request for `model`. +pub fn a_generate_model_request(model: MorphirModel) -> GenerateModelRequest { + GenerateModelRequest { + model, + input: a_json_tuple(ModelOperation::GenerateModel), + target: "rust".into(), + context_digest: "1".repeat(64), + required_guarantees: vec![], + options: Default::default(), + } +} + +/// Admit a compiled model envelope for `operation` and decode its payload as JSON. +pub fn payload_json(model: &MorphirModel, operation: ModelOperation) -> Value { + let retained = morphir_extension_sdk::model::retain_model( + model, + &a_json_tuple(operation), + &moonbit_host_limits(), + ) + .unwrap(); + serde_json::from_slice(retained.bytes()).unwrap() +} diff --git a/crates/morphir-rust-binding/tests/wasm.rs b/crates/morphir-rust-binding/tests/wasm.rs index 359b75045..9efeee4fb 100644 --- a/crates/morphir-rust-binding/tests/wasm.rs +++ b/crates/morphir-rust-binding/tests/wasm.rs @@ -32,6 +32,9 @@ mod pattern; #[path = "support/functions.rs"] mod functions; +#[path = "support/model.rs"] +mod model; + #[test] #[ignore = "requires a built Rust WASM guest"] fn wasm_compiles_and_generates_functions_and_lambdas_in_both_versions() { @@ -250,6 +253,8 @@ fn initialize(plugin: &mut Plugin) { .unwrap(); let initialized: InitializeResult = serde_json::from_value(result(plugin, request)).unwrap(); assert_eq!(initialized.protocol_version, MEP_VERSION); + // A host that offers only MEP 0.1 gets no model transport. + assert!(initialized.capabilities.model_transport.is_none()); assert_eq!(initialized.extension.id, "morphir-rust"); assert_eq!( initialized.extension.types, @@ -402,3 +407,92 @@ fn wasm_returns_protocol_errors_and_remains_usable() { ); assert_eq!(pong, json!({ "ok": true })); } + +#[test] +#[ignore = "requires a built Rust WASM guest; see this file's module documentation"] +fn wasm_serves_the_model_operations_like_native() { + use morphir_extension_sdk::{ + model::{MODEL_MEP_VERSION, ModelOperation, ModelTransportSupport}, + protocol::DescribeParams, + }; + let mut plugin = guest(); + let claims = result( + &mut plugin, + ExtensionRequest::new( + methods::DESCRIBE, + DescribeParams { + protocol_versions: vec![MEP_VERSION.into()], + }, + 1, + ) + .unwrap(), + ); + let mut versions: Vec = + serde_json::from_value(claims["protocolVersions"].clone()).unwrap(); + versions.sort(); + assert_eq!(versions, [MEP_VERSION, MODEL_MEP_VERSION]); + let json_tuples = [ + model::a_json_tuple(ModelOperation::CompileModel), + model::a_json_tuple(ModelOperation::GenerateModel), + ]; + let advertised: ModelTransportSupport = + serde_json::from_value(claims["capabilities"]["modelTransport"].clone()).unwrap(); + assert_eq!(advertised.tuples, json_tuples); + + let initialized: InitializeResult = serde_json::from_value(result( + &mut plugin, + ExtensionRequest::new( + methods::INITIALIZE, + InitializeParams { + model_transport: Some(model::a_moonbit_like_offer()), + protocol_versions: vec![MODEL_MEP_VERSION.into(), MEP_VERSION.into()], + host: PeerInfo { + kind: Default::default(), + name: "rust-binding-integration-test".into(), + version: "0.1.0".into(), + }, + }, + 2, + ) + .unwrap(), + )) + .unwrap(); + assert_eq!(initialized.protocol_version, MODEL_MEP_VERSION); + let agreed = initialized.capabilities.model_transport.unwrap(); + assert_eq!(agreed.tuples, json_tuples); + assert_eq!(agreed.limits, model::moonbit_host_limits()); + + let native = model::Session::on_model_line(); + let compiled = result( + &mut plugin, + ExtensionRequest::new(methods::COMPILE_MODEL, model::a_compile_model_request(), 3).unwrap(), + ); + let expected = native + .call(methods::COMPILE_MODEL, model::a_compile_model_request()) + .result + .unwrap(); + assert_eq!(compiled, expected); + let envelope = serde_json::from_value(compiled["model"].clone()).unwrap(); + + let generated = result( + &mut plugin, + ExtensionRequest::new( + methods::GENERATE_MODEL, + model::a_generate_model_request(envelope), + 4, + ) + .unwrap(), + ); + let generated: GenerateResult = serde_json::from_value(generated).unwrap(); + assert!(generated.success, "{:?}", generated.diagnostics); + model::assert_golden("generated-lib.rs", &generated.artifacts[0].content); + + let mut unagreed = + model::a_generate_model_request(serde_json::from_value(compiled["model"].clone()).unwrap()); + unagreed.input = model::an_ion_tuple(ModelOperation::GenerateModel); + let refused = call( + &mut plugin, + ExtensionRequest::new(methods::GENERATE_MODEL, unagreed, 5).unwrap(), + ); + assert!(refused.result.is_none() && refused.error.is_some()); +} diff --git a/openspec/changes/rust-extension-mep-0-2/.openspec.yaml b/openspec/changes/rust-extension-mep-0-2/.openspec.yaml new file mode 100644 index 000000000..e6331266d --- /dev/null +++ b/openspec/changes/rust-extension-mep-0-2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: html-design +created: 2026-10-31 diff --git a/openspec/changes/rust-extension-mep-0-2/design.html b/openspec/changes/rust-extension-mep-0-2/design.html new file mode 100644 index 000000000..452ddc01c --- /dev/null +++ b/openspec/changes/rust-extension-mep-0-2/design.html @@ -0,0 +1,138 @@ + + + + + +rust-extension-mep-0-2: design + + + + + + +
+

rust-extension-mep-0-2: design

+

OpenSpec change · Bead morphir-0xtc · 2026-10-31 · proposed

+ + + +

Context

+

See proposal.md, Why. The Rust extension is RustExtension in crates/morphir-rust-binding. It +has the frontend and backend roles, and export_extension!(RustExtension, frontend, backend) makes it +an Extism WASM guest (extism-kernel-v1). morphir-extension-sdk 0.4.0, in this workspace, +already does the MEP 0.2.0-draft.1 work for a guest that opts in:

+
    +
  • A guest opts in with ExtensionCapabilities::model_transport and implements +Frontend::compile_model and Backend::generate_model. The trait defaults refuse.
  • +
  • The guest protocol session adds 0.2.0-draft.1 to the describe claims when the support is valid, +selects it at initialize only when the host sends a model transport offer, intersects the complete +tuples in the host's order and takes the smaller limits.
  • +
  • Before dispatch, it refuses a rich request whose tuple was not agreed, whose envelope does not match the +tuple or whose payload fails its length or SHA-256 check. After the call, it checks a successful compile +result's model.
  • +
  • The Ion reader is native only. On wasm32, a guest that advertises an Ion tuple is invalid.
  • +
+

The reference is finos/morphir-gleam rust/crates/morphir-gleam-binding/src/model_transport.rs +(release extension/gleam/v0.4.0). The MoonBit CLI host offers morphir-model / +ion-binary, then morphir-ir / morphir-json, both at IR 4.0.0, and +uses the first tuple that the extension agrees on.

+ +

Goals and non-goals

+

Goals: the requirements in +specs/rust-extension-model-operations/spec.md, through the SDK's existing opt-in, with no SDK +change; MEP 0.1 behaviour unchanged; a released bundle that the MoonBit worker host runs on both +lines.

+

Non-goals: Ion transport; IR v3 model tuples; multi-document compilation or compile +dependencies (the frontend still compiles one document and refuses dependencies); release attestations, +which this repository's extension release does not make today; the finos/morphir bundle pins.

+ +

Decisions

+ +

MEP-1. Use the SDK opt-in, not a second protocol layer

+

RustExtension::capabilities() sets model_transport, and the Frontend and +Backend impls forward compile_model and generate_model to a new private +module model_transport. The SDK session then gives negotiation, the 0.1 fallback and the +admission checks to both the native NativeExtension dispatch and the WASM guest. The alternative, a +guest-specific protocol layer like the Gleam core-Wasm guest, would duplicate the session for no gain: the +Extism route already reaches the MoonBit worker host.

+ +

MEP-2. One JSON tuple per operation, at IR 4.0.0

+
{"operation": "morphir.frontend.compileModel", "profile": "morphir-ir", "codec": "morphir-json",
+ "envelopeVersion": "0.1.0-draft.1", "irVersion": "4.0.0"}
+

and the same tuple for morphir.backend.generateModel. No Ion tuple: the release artifact is a +WASM guest, and the SDK refuses Ion support on wasm32 rather than transcode +(morphir-opua.1.3.1.3 tracks a WASM Ion reader). A native-only Ion tuple would make the native +and WASM claims differ, and the packager reads the claims from the WASM. No IR 3 tuple: the hosts offer only +IR 4.0.0, and IR 3 stays available on the 0.1 line.

+ +

MEP-3. The payload is the legacy IR v4 document

+

compileModel runs the existing compile with irVersion 4 and encodes +the returned IR v4 JSON with ModelPayload::from_bytes. modelId is the package name and +revision is sha256: and the payload digest, as in the Gleam extension. +generateModel decodes the payload through the SDK's retain_model with the extension's +own tuple and limits, then runs the existing generate. So the two lines give the same IR and the same Rust, +and the golden tests compare both lines with one recorded output.

+ +

MEP-4. Refusals

+

The SDK session refuses an unagreed tuple, a wrong envelope and a failed integrity check before the +extension runs. The module checks the tuple again, because the native trait methods can be called without a +session, and refuses with InvalidParams. A required guarantee gives a failed +GenerateResult with diagnostic RS_UNSUPPORTED_GUARANTEE; the session already refuses it +first. A frontend failure is CompileModelResult::Failed with the frontend's diagnostics.

+ +

MEP-5. Limits

+

The guest advertises 64 MiB encoded, 48 MiB decoded, depth 128 and 16 Mi nodes. Hosts take the smaller value +of each limit (the MoonBit host offers 2 MiB decoded), so these are upper bounds only. The host's WASM memory +and instruction budgets still apply. Depth 128 is the deepest document that serde_json decodes +with its default recursion limit, counted as admission counts depth, so every payload that the session admits +can be decoded. The Gleam extension's depth 1024 was not taken: a host that agreed a depth above 128 could +admit a payload that the decoder then refuses. Disabling the recursion limit was not chosen either, because a +deep document could then overflow the WASM guest's stack.

+ +

REL-1. Release 0.4.0 with the existing workflow

+

.github/extensions.toml sets mep_versions = ["0.1", "0.2.0-draft.1"]; the packager +already compares it with the claims that it reads from the exact WASM, and the claims carry +modelTransport. The binding version moves to 0.4.0 (a new minor: a new protocol +line). The tag extension/rust/v0.4.0 on the merge commit starts the existing release workflow, +which publishes the WASM, .wasm.sha256 and release.json. The dry run is the same +resolution, build and asset validation, run locally on the merged commit before the tag.

+ +

Risks and trade-offs

+ + + + + + + +
RiskMitigation
An older host reads 0.2.0-draft.1 or modelTransport in the descriptor claims and +refuses the bundle.Run the existing 0.1-only paths: the installed-bundle test of +morphir-host-native and mise run test:cli-release rust with the pinned finos/morphir +CLI.
A host compares the claims of initialize (narrowed tuples and limits) with the describe +claims and finds a difference.Qualify on the MoonBit worker host, native and npm, before release. +A difference that the SDK causes is fixed in a separate SDK pull request first.
A model larger than the host's WASM budgets fails where the legacy line passes.The payload is +the same document as the legacy result; the budgets are the host's, not the transport's.
+ +

Migration plan

+

Additive. Hosts that offer only MEP 0.1 see no change. To roll back, publish nothing more and +keep the v0.3.0 pins; a released tag is not moved or deleted.

+ +
+ + diff --git a/openspec/changes/rust-extension-mep-0-2/proposal.md b/openspec/changes/rust-extension-mep-0-2/proposal.md new file mode 100644 index 000000000..d0f8d6e67 --- /dev/null +++ b/openspec/changes/rust-extension-mep-0-2/proposal.md @@ -0,0 +1,47 @@ +# Proposal + +## Why + +The released Rust extension (`morphir-rust-binding`, `extension/rust/v0.3.0`) declares MEP `0.1` only. Hosts +that use the MEP `0.2.0-draft.1` model operations, such as the MoonBit CLI worker host, can only run it on the +legacy line. The Morphir maintainers want every extension on the latest MEP. The Gleam extension +(`extension/gleam/v0.4.0`) and the Elm extension (`extension/elm/v0.5.0-preview.2`) already declare it, and +`morphir-extension-sdk` 0.4.0 implements the model operations. + +## What Changes + +- The Rust extension declares MEP `0.2.0-draft.1` in addition to MEP `0.1`. A host that offers only `0.1` + continues to get the `0.1` line with no model transport. +- The frontend serves `morphir.frontend.compileModel` and the backend serves `morphir.backend.generateModel` + for one tuple each: profile `morphir-ir`, codec `morphir-json`, envelope `0.1.0-draft.1`, IR `4.0.0`. +- The extension does not offer Ion. The released artifact is a WASM guest, and the SDK has no Ion reader for + WASM targets. +- The capability claims (`claimsVersion` `0.1.0-draft.2`) carry the new protocol version and the model + tuples. The registry entry in `.github/extensions.toml` declares both MEP versions. +- Release `extension/rust/v0.4.0` with the same asset shape as `v0.3.0`: the WASM, its `.wasm.sha256` and the + version-2 `release.json` descriptor. + +## Capabilities + +### New Capabilities + +- `rust-extension-model-operations`: how the Rust extension negotiates MEP versions and model transport + tuples, compiles Rust source into a model envelope, generates Rust from a model envelope, and refuses + requests outside the agreed tuple. + +### Modified Capabilities + +None. + +## Beads + +- **Tracked by**: `morphir-0xtc` +- **Related**: None found + +## Impact + +- `crates/morphir-rust-binding`: new model transport module, capabilities, tests and README. +- `.github/extensions.toml`: `mep_versions` of the `rust` entry. +- `CHANGELOG.md`, the binding's version (0.3.0 to 0.4.0) and `Cargo.lock`. +- No change to `morphir-extension-sdk` or to other repositories. The finos/morphir bundle pins follow in a + separate pull request. diff --git a/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md b/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md new file mode 100644 index 000000000..20f8c06a5 --- /dev/null +++ b/openspec/changes/rust-extension-mep-0-2/specs/rust-extension-model-operations/spec.md @@ -0,0 +1,95 @@ +# Spec Delta + +## Purpose + +Defines how the Rust extension (`morphir-rust`) takes part in MEP `0.2.0-draft.1`: which protocol versions and +model transport tuples it declares, how it compiles Rust source into a model envelope and generates Rust from +one, and which requests it refuses. + +## ADDED Requirements + +### Requirement: Declared protocol versions +The extension SHALL declare MEP `0.2.0-draft.1` and MEP `0.1` in its describe claims, in its release +descriptor claims and in its registry entry. It SHALL select `0.2.0-draft.1` only when the host offers that +version together with a model transport offer, and SHALL select `0.1` otherwise when the host offers `0.1`. + +#### Scenario: A host that offers only MEP 0.1 +- **WHEN** a host initializes the extension with protocol versions `["0.1"]` +- **THEN** the extension selects `0.1` +- **AND** the initialize result has no `modelTransport` member + +#### Scenario: A host that offers MEP 0.2.0-draft.1 with a model transport offer +- **WHEN** a host initializes the extension with protocol versions `["0.2.0-draft.1", "0.1"]` and a model + transport offer that contains the extension's tuples +- **THEN** the extension selects `0.2.0-draft.1` +- **AND** the initialize result's `modelTransport` holds the tuples that both peers support, in the host's + order, and the smaller of each limit + +#### Scenario: A host that offers MEP 0.2.0-draft.1 without a model transport offer +- **WHEN** a host initializes the extension with protocol versions `["0.2.0-draft.1", "0.1"]` and no model + transport offer +- **THEN** the extension selects `0.1` + +### Requirement: Advertised model transport tuples +The extension SHALL advertise exactly two model transport tuples, both with profile `morphir-ir`, codec +`morphir-json`, envelope version `0.1.0-draft.1` and IR version `4.0.0`: one for +`morphir.frontend.compileModel` and one for `morphir.backend.generateModel`. It SHALL NOT advertise an Ion +tuple and SHALL NOT advertise a guarantee. + +#### Scenario: Describe claims list the tuples +- **WHEN** a host sends `morphir.extension.describe` +- **THEN** `capabilities.modelTransport.tuples` holds exactly the two tuples +- **AND** `capabilities.modelTransport.guarantees` is empty + +#### Scenario: A host that offers only Ion +- **WHEN** a host offers MEP `0.2.0-draft.1` with only `morphir-model` / `ion-binary` tuples +- **THEN** the agreed model transport holds no tuple, so no model operation is available in the session + +### Requirement: Compile Rust source into a model envelope +For an agreed `morphir.frontend.compileModel` tuple, the extension SHALL compile the request's source with the +same rules as `morphir.frontend.compile` at IR v4. A successful result SHALL carry a model envelope whose +payload is the IR v4 JSON document that `morphir.frontend.compile` returns for the same source, with the +envelope's tuple, `modelId` equal to the package name and `revision` equal to `sha256:` and the payload digest. + +#### Scenario: A supported source file +- **WHEN** a host sends `morphir.frontend.compileModel` for one supported `.rs` document +- **THEN** the result has `success: true`, the compiled module names and a model envelope +- **AND** the decoded payload equals the recorded golden IR v4 document + +#### Scenario: A source file outside the supported subset +- **WHEN** a host sends `morphir.frontend.compileModel` for a document that the frontend rejects +- **THEN** the result has `success: false` and the frontend's diagnostics +- **AND** the result has no `model` member + +### Requirement: Generate Rust from a model envelope +For an agreed `morphir.backend.generateModel` tuple, the extension SHALL decode the model envelope's payload +and generate Rust with the same rules as `morphir.backend.generate`, for target `rust`. + +#### Scenario: A model envelope from compileModel +- **WHEN** a host sends `morphir.backend.generateModel` with the envelope that `compileModel` returned +- **THEN** the result has `success: true` and the artifacts that `morphir.backend.generate` returns for the + payload's IR +- **AND** the generated `lib.rs` equals the recorded golden output + +### Requirement: Refuse requests outside the agreed tuple +The extension SHALL refuse a model operation whose tuple was not agreed in the session, whose envelope does +not match the tuple, or whose payload does not match its length or SHA-256, with a JSON-RPC error and without +running the frontend or backend. It SHALL refuse a generation that requires a guarantee. + +#### Scenario: A tuple that was not agreed +- **WHEN** a host sends `morphir.backend.generateModel` with an Ion tuple, or with an IR version other than + `4.0.0` +- **THEN** the response is a JSON-RPC error and has no result + +#### Scenario: A model envelope with another envelope version +- **WHEN** a host sends `morphir.backend.generateModel` with a model whose `envelopeVersion` is not + `0.1.0-draft.1` +- **THEN** the response is a JSON-RPC error and has no result + +#### Scenario: A model operation on the MEP 0.1 line +- **WHEN** a session selected MEP `0.1` and the host sends `morphir.frontend.compileModel` +- **THEN** the response is a JSON-RPC error + +#### Scenario: A generation that requires a guarantee +- **WHEN** a host sends `morphir.backend.generateModel` with a required guarantee +- **THEN** the extension does not generate artifacts diff --git a/openspec/changes/rust-extension-mep-0-2/tasks.md b/openspec/changes/rust-extension-mep-0-2/tasks.md new file mode 100644 index 000000000..7b6f8edfc --- /dev/null +++ b/openspec/changes/rust-extension-mep-0-2/tasks.md @@ -0,0 +1,62 @@ +# Tasks + +## 1. Model operations in the Rust binding (morphir-0xtc.1) + +- [x] 1.1 Write failing native tests for negotiation: a `0.1`-only host gets `0.1` and no `modelTransport`; a + `0.2.0-draft.1` host with an offer gets the agreed tuples and the smaller limits; an offer without + `modelTransport` gets `0.1`; an Ion-only offer agrees on no tuple. Verify they fail before 1.3. +- [x] 1.2 Write failing native tests for `compileModel` and `generateModel` with golden IR and golden Rust + under `crates/morphir-rust-binding/tests/fixtures/model/`, and for the refusals (unagreed tuple, wrong + envelope version, payload digest mismatch, model operation on the `0.1` line, required guarantee, frontend + failure). Verify they fail before 1.3. +- [x] 1.3 Add the `model_transport` module and wire `capabilities()`, `compile_model` and `generate_model`; + verify `cargo test --locked -p morphir-rust-binding` passes. +- [x] 1.4 Verify the compiled model's payload equals the `morphir.frontend.compile` IR v4 result and the + generated `lib.rs` equals the `morphir.backend.generate` result for the same input (test). +- [x] 1.5 Verify `cargo clippy --locked -p morphir-rust-binding --all-targets -- -D warnings` and + `cargo fmt --check` pass. +- [x] 1.6 Document the model operations, the tuples and the absence of Ion in the binding README; verify the + README states the claimed protocol versions. + +## 2. WASM guest, claims and release descriptor (morphir-0xtc.2) + +- [x] 2.1 Set `mep_versions = ["0.1", "0.2.0-draft.1"]` for `rust` in `.github/extensions.toml`; verify + `python3 -m unittest discover -s tests/ci` passes. +- [x] 2.2 Extend the ignored WASM host test (`tests/wasm.rs`) to describe the guest, run both model + operations through the guest and compare with the native results; verify it passes against the release + WASM. +- [x] 2.3 Verify the 0.1-only host paths still pass: the installed-bundle test + `cargo test -p morphir-host-native --test rust_extension -- --ignored` (through + `mise run extension:artifact:rust`) and `mise run test:cli-release rust`. +- [x] 2.4 Verify the built `release.json` claims hold `protocolVersions` `0.2.0-draft.1` and `0.1`, + `claimsVersion` `0.1.0-draft.2` and the two model tuples. +- [x] 2.5 Add a `CHANGELOG.md` entry under `[Unreleased]`; verify `mise run release:changelog-validate`. + +## 3. Qualify on the MoonBit worker host (morphir-0xtc.3) + +- [x] 3.1 Build the native and npm CLIs from finos/morphir-moonbit `main` and record the revision. +- [x] 3.2 With a contract `0.2.0` pin (WASM and descriptor), run `extension describe`, `extension compile` + (model line) and `extension generate` (model envelope) from the native CLI; check the outputs against the + golden files and that each process exits by itself. +- [x] 3.3 Run the same operations from the npm CLI. +- [x] 3.4 Run generate once on the `0.1` line (plain IR) from both CLIs, and record the receipts in the pull + request. The MoonBit host compiles on the model line whenever the extension claims `compileModel`, so the + `0.1` compile runs on the 0.1-only hosts of task 2.3. + +## 4. Release extension/rust/v0.4.0 (morphir-0xtc.4) + +- [x] 4.1 Bump `morphir-rust-binding` to `0.4.0`, update `Cargo.lock`, the README version references and the + changelog release section; verify `cargo test --locked -p morphir-rust-binding` passes. +- [ ] 4.2 Open the pull request, get CI green and handle each verified review finding. +- [ ] 4.3 Dry run on the merge candidate: `.github/scripts/extension_release.py` for the tag, + `mise run extension:artifact:rust` from a clean checkout and `select_extension_assets.py + --validate-short-id rust`; record the asset digests. +- [ ] 4.4 After approval, merge and tag `extension/rust/v0.4.0` on the merge commit; verify the release + workflow publishes the WASM, `.wasm.sha256` and `release.json`. +- [ ] 4.5 Verify the published assets: digests equal the dry run, and `extension describe` on the + downloaded bundle from the MoonBit native CLI reports MEP `0.1, 0.2.0-draft.1`. + +## Workflow follow-up + +- Archive the change after the release, and close the epic. +- Report the release, tag, asset digests and qualification receipts to the main session.