From 45bde51166e56e5f60f784fecfc97f38bc6d7915 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Sun, 17 May 2026 22:31:26 -0400 Subject: [PATCH] Mizan-Rust backend adapter: server-side substrate + three-way parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class Rust-backed Mizan to sit alongside mizan-django and mizan-fastapi. A Rust dev writes: #[derive(Mizan, Serialize, Deserialize)] pub struct ProfileOutput { pub user_id: i64, pub name: String } #[mizan::context("user")] pub struct UserCtx; #[mizan::client(context = UserCtx)] pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... } …and gets byte-identical KDL to the Python emitters, served over the same wire protocol the React / Rust / Vue / Svelte kernels speak. New crates: - cores/mizan-rust/ (Cargo: mizan-core) — IR types, KDL emitter, traits, registry, runtime (compute_invalidation / compute_merges ported from mizan-fastapi), graph_check with structural type-matching - cores/mizan-rust-macros/ (Cargo: mizan-macros) — #[derive(Mizan)], #[mizan::context], #[mizan::client] proc macros - backends/mizan-rust-axum/ (Cargo: mizan-axum) — axum HTTP adapter: /session/, /call/, /ctx/:name/ - tests/afi/rust_app/ — AFI fixture port + server / export-ir binaries Substrate-shape moves required by cross-language equivalence: - IR canonicalization: functions / contexts / context-members / shared-by now sort alphabetically in both Python and Rust emitters. The IR is a contract; linkme doesn't preserve declaration order, so canonical sort is the only stable mapping. afi_ir.kdl + per-target baselines regenerated. - MizanType::TYPE_NAME is a const (with a default type_name() reader) so it's usable in linkme TypeEntry static initializers. - Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl; the #[mizan::client] macro registers canonical-named entries from fn signatures, including Vec element types for ref resolution. - Merge resolution is structural (NamedType shape comparison) rather than by name — matches the Python types_match_for_merge semantics. Three-way forcing functions: - tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass) - tests/rust/run_wire_parity.py — 12/12 probes against FastAPI + Rust (EXIT=0) Incidental fixes surfaced by the new tests: - Stale `from .registry import validate_registry` import removed from mizan-django/setup/discovery.py (referenced a function that no longer exists; was masking codegen-parity). - BASE_DIR added to tests/afi/django_app/project/settings.py. - /session/ endpoint added to mizan-fastapi for protocol-shaped readiness probe parity (wire-parity harness now polls /api/mizan/session/ on both backends rather than FastAPI's /openapi.json). - Root .gitignore picks up Rust target/ across the tree so new crates don't need per-crate gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + .../mizan-django/src/mizan/setup/discovery.py | 3 - .../mizan-fastapi/src/mizan_fastapi/router.py | 11 + backends/mizan-rust-axum/Cargo.lock | 591 +++++++++++++++ backends/mizan-rust-axum/Cargo.toml | 15 + backends/mizan-rust-axum/src/errors.rs | 27 + backends/mizan-rust-axum/src/handlers.rs | 153 ++++ backends/mizan-rust-axum/src/lib.rs | 37 + cores/mizan-python/src/mizan_core/ir.py | 17 +- cores/mizan-rust-macros/Cargo.toml | 15 + cores/mizan-rust-macros/src/context.rs | 77 ++ cores/mizan-rust-macros/src/derive.rs | 102 +++ cores/mizan-rust-macros/src/function.rs | 494 +++++++++++++ cores/mizan-rust-macros/src/lib.rs | 58 ++ cores/mizan-rust-macros/src/shape.rs | 123 ++++ cores/mizan-rust/Cargo.lock | 173 +++++ cores/mizan-rust/Cargo.toml | 16 + cores/mizan-rust/src/graph_check.rs | 165 +++++ cores/mizan-rust/src/ir.rs | 93 +++ cores/mizan-rust/src/kdl.rs | 397 ++++++++++ cores/mizan-rust/src/lib.rs | 57 ++ cores/mizan-rust/src/registry.rs | 47 ++ cores/mizan-rust/src/runtime.rs | 252 +++++++ cores/mizan-rust/src/traits.rs | 92 +++ cores/mizan-rust/tests/afi_parity.rs | 129 ++++ .../mizan-codegen/tests/fixtures/afi_ir.kdl | 64 +- .../tests/fixtures/baselines/python/client.py | 18 +- .../fixtures/baselines/react/contexts/user.ts | 4 +- .../tests/fixtures/baselines/react/index.ts | 4 +- .../tests/fixtures/baselines/react/react.tsx | 18 +- .../baselines/rust/src/contexts/user.rs | 4 +- .../baselines/stage1/contexts/user.ts | 4 +- .../tests/fixtures/baselines/stage1/index.ts | 4 +- .../baselines/svelte/contexts/user.ts | 4 +- .../tests/fixtures/baselines/svelte/index.ts | 4 +- .../tests/fixtures/baselines/svelte/svelte.ts | 4 +- .../fixtures/baselines/vue/contexts/user.ts | 4 +- .../tests/fixtures/baselines/vue/index.ts | 4 +- .../tests/fixtures/baselines/vue/vue.ts | 28 +- tests/afi/django_app/project/settings.py | 3 + tests/afi/rust_app/Cargo.lock | 679 ++++++++++++++++++ tests/afi/rust_app/Cargo.toml | 21 + tests/afi/rust_app/src/bin/export_ir.rs | 10 + tests/afi/rust_app/src/bin/server.rs | 25 + tests/afi/rust_app/src/lib.rs | 94 +++ tests/afi/test_codegen_parity.py | 84 ++- tests/rust/run_wire_parity.py | 102 ++- 47 files changed, 4187 insertions(+), 147 deletions(-) create mode 100644 backends/mizan-rust-axum/Cargo.lock create mode 100644 backends/mizan-rust-axum/Cargo.toml create mode 100644 backends/mizan-rust-axum/src/errors.rs create mode 100644 backends/mizan-rust-axum/src/handlers.rs create mode 100644 backends/mizan-rust-axum/src/lib.rs create mode 100644 cores/mizan-rust-macros/Cargo.toml create mode 100644 cores/mizan-rust-macros/src/context.rs create mode 100644 cores/mizan-rust-macros/src/derive.rs create mode 100644 cores/mizan-rust-macros/src/function.rs create mode 100644 cores/mizan-rust-macros/src/lib.rs create mode 100644 cores/mizan-rust-macros/src/shape.rs create mode 100644 cores/mizan-rust/Cargo.lock create mode 100644 cores/mizan-rust/Cargo.toml create mode 100644 cores/mizan-rust/src/graph_check.rs create mode 100644 cores/mizan-rust/src/ir.rs create mode 100644 cores/mizan-rust/src/kdl.rs create mode 100644 cores/mizan-rust/src/lib.rs create mode 100644 cores/mizan-rust/src/registry.rs create mode 100644 cores/mizan-rust/src/runtime.rs create mode 100644 cores/mizan-rust/src/traits.rs create mode 100644 cores/mizan-rust/tests/afi_parity.rs create mode 100644 tests/afi/rust_app/Cargo.lock create mode 100644 tests/afi/rust_app/Cargo.toml create mode 100644 tests/afi/rust_app/src/bin/export_ir.rs create mode 100644 tests/afi/rust_app/src/bin/server.rs create mode 100644 tests/afi/rust_app/src/lib.rs diff --git a/.gitignore b/.gitignore index f5d4569..0a8911b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ node_modules/ dist/ package-lock.json +# Rust — every crate's build dir, anywhere in the tree +target/ +**/target/ + # Playwright /test-results/ /playwright-report/ diff --git a/backends/mizan-django/src/mizan/setup/discovery.py b/backends/mizan-django/src/mizan/setup/discovery.py index cd7043f..d9f2f13 100644 --- a/backends/mizan-django/src/mizan/setup/discovery.py +++ b/backends/mizan-django/src/mizan/setup/discovery.py @@ -71,9 +71,6 @@ def mizan_clients(apps_root: str, layer: str = "clients") -> None: visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root) visitor.visit(_RegisterServerFunctions()) - from .registry import validate_registry - validate_registry() - def mizan_module(module_path: str) -> None: """ diff --git a/backends/mizan-fastapi/src/mizan_fastapi/router.py b/backends/mizan-fastapi/src/mizan_fastapi/router.py index 19dcb28..de8c828 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/router.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/router.py @@ -43,6 +43,17 @@ def _no_store(payload: Any, status_code: int = 200) -> JSONResponse: # ─── Endpoints ────────────────────────────────────────────────────────────── +@router.get("/session/") +async def session_init() -> JSONResponse: + """Session-init probe. Parity with mizan-django's session endpoint. + + CSRF is a Django-only concern at the protocol level; FastAPI surfaces a + null token so the response shape stays uniform across backends. The + wire-parity harness uses this endpoint as its readiness probe. + """ + return _no_store({"csrfToken": None}) + + class CallBody(BaseModel): fn: str = Field(..., min_length=1) args: dict[str, Any] = Field(default_factory=dict) diff --git a/backends/mizan-rust-axum/Cargo.lock b/backends/mizan-rust-axum/Cargo.lock new file mode 100644 index 0000000..59bfc10 --- /dev/null +++ b/backends/mizan-rust-axum/Cargo.lock @@ -0,0 +1,591 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "mizan-axum" +version = "0.1.0" +dependencies = [ + "axum", + "mizan-core", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", +] + +[[package]] +name = "mizan-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "linkme", + "mizan-macros", + "serde", + "serde_json", +] + +[[package]] +name = "mizan-macros" +version = "0.1.0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/backends/mizan-rust-axum/Cargo.toml b/backends/mizan-rust-axum/Cargo.toml new file mode 100644 index 0000000..a54be20 --- /dev/null +++ b/backends/mizan-rust-axum/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mizan-axum" +version = "0.1.0" +edition = "2021" +description = "axum HTTP adapter for Mizan — typed RPC dispatch + context-bundle fetch on top of mizan-core's compile-time function registry." +license = "MIT" + +[dependencies] +mizan-core = { path = "../../cores/mizan-rust" } +axum = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower = "0.5" +tower-http = { version = "0.6", features = ["trace"] } diff --git a/backends/mizan-rust-axum/src/errors.rs b/backends/mizan-rust-axum/src/errors.rs new file mode 100644 index 0000000..1aa71e1 --- /dev/null +++ b/backends/mizan-rust-axum/src/errors.rs @@ -0,0 +1,27 @@ +//! Convert `MizanError` into axum's `Response`. Mirrors mizan-fastapi's +//! envelope: `{"error": {"code": "...", "message": "...", "details": ...}}` +//! with a Cache-Control: no-store header. + +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use mizan_core::MizanError; + +pub struct ApiError(pub MizanError); + +impl From for ApiError { + fn from(e: MizanError) -> Self { + Self(e) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = StatusCode::from_u16(self.0.http_status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut resp = (status, Json(self.0.to_json())).into_response(); + resp.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + resp + } +} diff --git a/backends/mizan-rust-axum/src/handlers.rs b/backends/mizan-rust-axum/src/handlers.rs new file mode 100644 index 0000000..03352fd --- /dev/null +++ b/backends/mizan-rust-axum/src/handlers.rs @@ -0,0 +1,153 @@ +//! HTTP handlers. Mirrors `backends/mizan-fastapi/src/mizan_fastapi/router.py`. + +use axum::extract::{Path, Query}; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use mizan_core::{ + compute_invalidation, compute_merges, lookup_function, lookup_context, FunctionSpec, + InvalidationTarget, MergeEntry, MizanError, RequestHandle, FUNCTIONS, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +use crate::errors::ApiError; + +/// Body for POST /call/. Matches the Python `CallBody` shape. +#[derive(Debug, Deserialize)] +pub struct CallBody { + pub fn_: Option, + #[serde(rename = "fn")] + pub function_name: Option, + #[serde(default)] + pub args: Map, +} + +impl CallBody { + fn resolved_name(&self) -> Option<&str> { + self.function_name + .as_deref() + .or(self.fn_.as_deref()) + } +} + +#[derive(Debug, Serialize)] +pub struct CallResponse { + pub result: Value, + pub invalidate: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub merge: Option>, +} + +fn no_store(json: Value) -> Response { + let mut resp = (StatusCode::OK, Json(json)).into_response(); + resp.headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + resp +} + +/// POST /call/ — RPC dispatch. +pub async fn function_call(Json(body): Json) -> Result { + let fn_name = body + .resolved_name() + .ok_or_else(|| ApiError(MizanError::BadRequest("missing `fn` field".into())))? + .to_string(); + + let fn_spec = lookup_function(&fn_name) + .ok_or_else(|| ApiError(MizanError::NotFound(format!("function {fn_name:?} not registered"))))?; + + let unit = (); + let req = RequestHandle::new(&unit); + let result = fn_spec.dispatch(req, Value::Object(body.args.clone())).await.map_err(ApiError)?; + + let invalidate: Vec = compute_invalidation(fn_spec, &body.args) + .iter() + .map(InvalidationTarget::to_json) + .collect(); + let merges = compute_merges(fn_spec, &body.args, &result); + let merge_payload: Option> = if merges.is_empty() { + None + } else { + Some(merges.iter().map(MergeEntry::to_json).collect()) + }; + + let payload = CallResponse { + result, + invalidate, + merge: merge_payload, + }; + Ok(no_store(serde_json::to_value(&payload).unwrap())) +} + +/// GET /ctx/:context_name/ — bundled context fetch. +pub async fn context_fetch( + Path(context_name): Path, + Query(params): Query>, +) -> Result { + if lookup_context(&context_name).is_none() { + return Err(ApiError(MizanError::NotFound(format!( + "context {context_name:?} not registered" + )))); + } + + let members: Vec<&dyn FunctionSpec> = FUNCTIONS + .iter() + .copied() + .filter(|f| f.context() == Some(&context_name)) + .collect(); + if members.is_empty() { + return Err(ApiError(MizanError::NotFound(format!( + "context {context_name:?} has no registered members" + )))); + } + + // Convert query params (all-string values) to the JSON arg map. Numeric + // params get parsed via the per-function input_params primitive table. + let mut bundled = Map::new(); + let unit = (); + for fn_spec in &members { + let args = coerce_query_args(*fn_spec, ¶ms); + let req = RequestHandle::new(&unit); + let result = fn_spec.dispatch(req, Value::Object(args)).await.map_err(ApiError)?; + bundled.insert(fn_spec.name().to_string(), result); + } + + Ok(no_store(Value::Object(bundled))) +} + +/// Coerce string-valued query params into typed JSON values using the +/// function's declared input_params. Strings that don't parse stay as +/// strings — the dispatch wrapper will raise ValidationFailed downstream. +fn coerce_query_args( + fn_spec: &dyn FunctionSpec, + params: &BTreeMap, +) -> Map { + let mut out = Map::new(); + for ip in fn_spec.input_params() { + if let Some(raw) = params.get(ip.name) { + let parsed = match ip.primitive { + mizan_core::Primitive::Integer => raw.parse::().ok().map(Value::from), + mizan_core::Primitive::Number => raw.parse::().ok().and_then(|v| { + serde_json::Number::from_f64(v).map(Value::Number) + }), + mizan_core::Primitive::Boolean => raw.parse::().ok().map(Value::from), + mizan_core::Primitive::String => Some(Value::from(raw.clone())), + }; + if let Some(v) = parsed { + out.insert(ip.name.into(), v); + } else { + out.insert(ip.name.into(), Value::from(raw.clone())); + } + } + } + out +} + +/// GET /session/ — placeholder for the Mizan-protocol session-init endpoint. +/// CSRF is a Django-only concern; the Rust adapter returns a null token so +/// readiness-probe consumers see a well-formed response. +pub async fn session_init() -> Response { + let body = serde_json::json!({ "csrfToken": null }); + no_store(body) +} diff --git a/backends/mizan-rust-axum/src/lib.rs b/backends/mizan-rust-axum/src/lib.rs new file mode 100644 index 0000000..a28a36d --- /dev/null +++ b/backends/mizan-rust-axum/src/lib.rs @@ -0,0 +1,37 @@ +//! Mizan axum HTTP adapter — typed RPC over `mizan-core`'s function registry. +//! +//! Usage: +//! ```ignore +//! use axum::Router; +//! use mizan_axum::router; +//! +//! #[tokio::main] +//! async fn main() { +//! let app = Router::new().nest("/api/mizan", router()); +//! let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap(); +//! axum::serve(listener, app).await.unwrap(); +//! } +//! ``` +//! +//! Exposed endpoints (mirroring `mizan-fastapi` / `mizan-django`): +//! * `GET /session/` — session-init probe (placeholder CSRF token) +//! * `POST /call/` — RPC dispatch with invalidate+merge response +//! * `GET /ctx/:name/` — bundled context fetch + +mod errors; +mod handlers; + +pub use errors::ApiError; +pub use handlers::{context_fetch, function_call, session_init, CallBody, CallResponse}; + +use axum::routing::{get, post}; +use axum::Router; + +/// Build the Mizan router. Mount it under a prefix: +/// `Router::new().nest("/api/mizan", router())`. +pub fn router() -> Router { + Router::new() + .route("/session/", get(handlers::session_init)) + .route("/call/", post(handlers::function_call)) + .route("/ctx/:context_name/", get(handlers::context_fetch)) +} diff --git a/cores/mizan-python/src/mizan_core/ir.py b/cores/mizan-python/src/mizan_core/ir.py index f07c723..e9838a8 100644 --- a/cores/mizan-python/src/mizan_core/ir.py +++ b/cores/mizan-python/src/mizan_core/ir.py @@ -469,7 +469,11 @@ def build_ir() -> str: lines.append("") # ── Functions ── - for fn_name, fn_class in functions.items(): + # Alphabetical by wire name — the IR is a canonical contract, not a + # transcript of registration order. Both Python and Rust emitters sort + # so byte-equivalence holds across language-backed backends. + for fn_name in sorted(functions): + fn_class = functions[fn_name] meta = getattr(fn_class, "_meta", {}) if meta.get("private") or meta.get("view_path"): continue @@ -481,8 +485,9 @@ def build_ir() -> str: lines.append("") # ── Contexts ── - for ctx_name, fn_names in context_groups.items(): - _emit_context(root, ctx_name, fn_names) + # Alphabetical by context name — same reason as functions above. + for ctx_name in sorted(context_groups): + _emit_context(root, ctx_name, context_groups[ctx_name]) if context_groups: lines.append("") @@ -541,14 +546,16 @@ def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None: slot["required"] = len(slot["shared_by"]) == len(fn_names) with root.node("context", _kdl_string(ctx_name)) as block: - for fn_name in fn_names: + # Members alphabetical — canonical order. + for fn_name in sorted(fn_names): block.leaf("function", _kdl_string(fn_name)) for param_name in sorted(param_info): slot = param_info[param_name] with block.node("param", _kdl_string(param_name)) as param_block: param_block.leaf("type", _kdl_string(slot["type"])) param_block.leaf("required", _kdl_bool(slot["required"])) - for sharer in slot["shared_by"]: + # `shared-by` follows the same canonical ordering. + for sharer in sorted(slot["shared_by"]): param_block.leaf("shared-by", _kdl_string(sharer)) diff --git a/cores/mizan-rust-macros/Cargo.toml b/cores/mizan-rust-macros/Cargo.toml new file mode 100644 index 0000000..a1f6580 --- /dev/null +++ b/cores/mizan-rust-macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mizan-macros" +version = "0.1.0" +edition = "2021" +description = "Proc macros for mizan-core: #[derive(Mizan)], #[mizan::context], #[mizan(...)]. Emits MizanType / ContextMarker / FunctionSpec impls plus linkme registrations." +license = "MIT" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } +heck = "0.5" diff --git a/cores/mizan-rust-macros/src/context.rs b/cores/mizan-rust-macros/src/context.rs new file mode 100644 index 0000000..d334e54 --- /dev/null +++ b/cores/mizan-rust-macros/src/context.rs @@ -0,0 +1,77 @@ +//! `#[mizan::context]` / `#[mizan::context("name")]` — emit `ContextMarker` +//! impl + linkme registration for a unit struct. + +use heck::ToSnakeCase; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token}; + +/// Attribute args: either nothing, or one string literal that overrides the +/// derived snake_case context name. +pub struct ContextArgs { + pub explicit_name: Option, +} + +impl ContextArgs { + pub fn parse(attr_tokens: TokenStream) -> syn::Result { + if attr_tokens.is_empty() { + return Ok(Self { explicit_name: None }); + } + // Support both `#[mizan::context("user")]` (string literal) and + // `#[mizan::context(name = "user")]` (key=value). + if let Ok(lit) = syn::parse2::(attr_tokens.clone()) { + return Ok(Self { + explicit_name: Some(lit.value()), + }); + } + let parser = Punctuated::::parse_terminated; + let metas = parser.parse2(attr_tokens)?; + for meta in metas { + if let Meta::NameValue(nv) = meta { + if nv.path.is_ident("name") { + if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value { + return Ok(Self { + explicit_name: Some(s.value()), + }); + } + } + } + } + Err(syn::Error::new_spanned( + "", + "expected `#[mizan::context]` or `#[mizan::context(\"\")]` or `#[mizan::context(name = \"\")]`", + )) + } +} + +pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream { + if !item.fields.is_empty() { + return syn::Error::new_spanned( + &item.fields, + "#[mizan::context] requires a unit struct — context markers carry no data.", + ) + .to_compile_error(); + } + + let ident = item.ident.clone(); + let name = args + .explicit_name + .unwrap_or_else(|| ident.to_string().to_snake_case()); + + let register_static = + format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase()); + + quote! { + #item + + impl ::mizan_core::ContextMarker for #ident { + const NAME: &'static str = #name; + } + + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CONTEXTS)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #register_static: ::mizan_core::ContextEntry = ::mizan_core::ContextEntry { + name: #name, + }; + } +} diff --git a/cores/mizan-rust-macros/src/derive.rs b/cores/mizan-rust-macros/src/derive.rs new file mode 100644 index 0000000..1aa0a8f --- /dev/null +++ b/cores/mizan-rust-macros/src/derive.rs @@ -0,0 +1,102 @@ +//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DataEnum, DataStruct, DeriveInput, Fields}; + +use crate::shape::type_shape_expr; + +/// Expand `#[derive(Mizan)]`. Emits only the `MizanType` trait impl — +/// registration into the IR `TYPES` slice happens at the function macro +/// (which owns the canonical-named type entries `Input` / +/// `Output`) and at sub-type discovery inside `Vec` outputs. +/// This keeps the type registry tree-shaken: only types actually reachable +/// from a registered function appear in the emitted IR. +pub fn expand(input: DeriveInput) -> TokenStream { + let ident = input.ident.clone(); + let type_name = ident.to_string(); + + let named_type_body = match &input.data { + Data::Struct(s) => emit_struct(s), + Data::Enum(e) => emit_enum(e), + Data::Union(_) => { + return syn::Error::new_spanned( + &input, + "#[derive(Mizan)] does not support `union` types — use a struct or enum.", + ) + .to_compile_error(); + } + }; + + quote! { + impl ::mizan_core::MizanType for #ident { + const TYPE_NAME: &'static str = #type_name; + fn shape() -> ::mizan_core::NamedType { #named_type_body } + } + } +} + +fn emit_struct(s: &DataStruct) -> TokenStream { + let fields = match &s.fields { + Fields::Named(named) => &named.named, + Fields::Unnamed(_) | Fields::Unit => { + return syn::Error::new_spanned( + &s.fields, + "#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.", + ) + .to_compile_error(); + } + }; + + let mut field_exprs: Vec = Vec::new(); + for field in fields { + let ident = field + .ident + .as_ref() + .expect("named field always has an ident"); + let name = ident.to_string(); + let shape = type_shape_expr(&field.ty); + + // A field is `required` iff its type is not `Option<...>`. Defaults + // are not encodable from Rust syntax (no `= expr` on a struct field + // declaration) — the macro emits `required: false, default: None` + // for Option-wrapped fields, leaving defaults for a future + // attribute-based extension. + let is_optional = crate::shape::unwrap_option(&field.ty).is_some(); + let required = !is_optional; + field_exprs.push(quote! { + ::mizan_core::StructField { + name: #name, + required: #required, + default: ::std::option::Option::None, + shape: #shape, + } + }); + } + + quote! { + ::mizan_core::NamedType::Struct(::std::vec![ + #(#field_exprs),* + ]) + } +} + +fn emit_enum(e: &DataEnum) -> TokenStream { + let mut variants: Vec = Vec::new(); + for variant in &e.variants { + if !matches!(variant.fields, Fields::Unit) { + return syn::Error::new_spanned( + &variant.fields, + "#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.", + ) + .to_compile_error(); + } + let name = variant.ident.to_string(); + variants.push(quote! { #name }); + } + quote! { + ::mizan_core::NamedType::Enum(::std::vec![ + #(#variants),* + ]) + } +} diff --git a/cores/mizan-rust-macros/src/function.rs b/cores/mizan-rust-macros/src/function.rs new file mode 100644 index 0000000..22e30a9 --- /dev/null +++ b/cores/mizan-rust-macros/src/function.rs @@ -0,0 +1,494 @@ +//! `#[mizan(...)]` — on async fns. Generates: +//! * a synthetic Input struct (`Input`) when the fn has params +//! * `MizanType` impl on the Input struct +//! * canonical type entries (`Input` / `Output`) +//! * Vec-element sub-type entries (so `Vec` outputs surface `T` too) +//! * `FunctionSpec` impl on a ZST `__MizanFn_` +//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN__INSTANCE` + +use heck::{ToLowerCamelCase, ToShoutySnakeCase}; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse::Parser, + punctuated::Punctuated, + spanned::Spanned, + Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type, +}; + +use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option}; + +/// Parsed attribute args for `#[mizan(...)]`. +#[derive(Default)] +pub struct FunctionArgs { + pub context: Option, + pub affects: Vec, + pub merge: Vec, + pub websocket: bool, + pub private: bool, +} + +impl FunctionArgs { + pub fn parse(attr_tokens: TokenStream) -> syn::Result { + if attr_tokens.is_empty() { + return Ok(Self::default()); + } + let parser = Punctuated::::parse_terminated; + let metas = parser.parse2(attr_tokens)?; + let mut out = Self::default(); + for meta in metas { + match meta { + Meta::NameValue(nv) => { + if nv.path.is_ident("context") { + out.context = Some(expect_path(&nv.value)?); + } else if nv.path.is_ident("affects") { + out.affects = collect_paths(&nv.value)?; + } else if nv.path.is_ident("merge") { + out.merge = collect_paths(&nv.value)?; + } else { + return Err(syn::Error::new_spanned( + nv.path, + "unknown attribute key; expected one of: context, affects, merge", + )); + } + } + Meta::Path(p) => { + if p.is_ident("websocket") { + out.websocket = true; + } else if p.is_ident("private") { + out.private = true; + } else { + return Err(syn::Error::new_spanned( + p, + "unknown flag; expected `websocket` or `private`", + )); + } + } + Meta::List(l) => { + return Err(syn::Error::new_spanned( + l, + "list-shaped attribute args not supported here", + )); + } + } + } + if out.context.is_some() && !out.affects.is_empty() { + return Err(syn::Error::new_spanned( + out.context.as_ref().unwrap(), + "`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.", + )); + } + if out.context.is_some() && !out.merge.is_empty() { + return Err(syn::Error::new_spanned( + out.context.as_ref().unwrap(), + "`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.", + )); + } + Ok(out) + } +} + +fn expect_path(expr: &Expr) -> syn::Result { + if let Expr::Path(ExprPath { path, .. }) = expr { + Ok(path.clone()) + } else { + Err(syn::Error::new_spanned( + expr, + "expected a type path (e.g. `UserCtx`)", + )) + } +} + +fn collect_paths(expr: &Expr) -> syn::Result> { + match expr { + Expr::Path(_) => Ok(vec![expect_path(expr)?]), + Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(), + _ => Err(syn::Error::new_spanned( + expr, + "expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)", + )), + } +} + +/// Information about one input parameter, extracted from the fn signature. +struct InputArg { + ident: syn::Ident, + ty: Type, +} + +pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream { + if item.sig.asyncness.is_none() { + return syn::Error::new_spanned( + &item.sig.fn_token, + "#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.", + ) + .to_compile_error(); + } + + let fn_name = item.sig.ident.to_string(); + let camel = fn_name.to_lower_camel_case(); + let input_type_name = format!("{camel}Input"); + let output_type_name = format!("{camel}Output"); + + let input_args = match collect_input_args(&item) { + Ok(v) => v, + Err(e) => return e.to_compile_error(), + }; + let has_input = !input_args.is_empty(); + let input_type_ident = format_ident!("{}", input_type_name); + + let return_ty = match &item.sig.output { + ReturnType::Type(_, t) => (**t).clone(), + ReturnType::Default => { + return syn::Error::new_spanned( + &item.sig, + "#[mizan] requires an explicit return type. Add `-> T` to the signature.", + ) + .to_compile_error(); + } + }; + let analysis = analyze_return(&return_ty); + + // ─── Synthetic Input struct ──────────────────────────────────────────── + let input_struct = if has_input { + let mut field_defs = Vec::new(); + let mut field_shapes = Vec::new(); + for arg in &input_args { + let ident = &arg.ident; + let ty = &arg.ty; + // Strip a leading underscore from the wire-level field name — + // Rust convention uses `_foo` to silence unused-arg warnings, + // but the wire schema and the Python fixture name the param + // `foo`. The struct field keeps its source ident (so the + // dispatch wrapper's `validated.#ident` compiles), and a serde + // `rename` bridges the wire-level JSON name. + let name_str = ident.to_string(); + let wire_name = name_str.trim_start_matches('_').to_string(); + let serde_rename = if wire_name != name_str { + quote! { #[serde(rename = #wire_name)] } + } else { + TokenStream::new() + }; + field_defs.push(quote! { #serde_rename pub #ident: #ty, }); + let is_optional = unwrap_option(ty).is_some(); + let required = !is_optional; + let shape = type_shape_expr(ty); + field_shapes.push(quote! { + ::mizan_core::StructField { + name: #wire_name, + required: #required, + default: ::std::option::Option::None, + shape: #shape, + } + }); + } + quote! { + #[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)] + pub struct #input_type_ident { + #(#field_defs)* + } + + impl ::mizan_core::MizanType for #input_type_ident { + const TYPE_NAME: &'static str = #input_type_name; + fn shape() -> ::mizan_core::NamedType { + ::mizan_core::NamedType::Struct(::std::vec![ + #(#field_shapes),* + ]) + } + } + } + } else { + TokenStream::new() + }; + + // ─── Type entry registrations ────────────────────────────────────────── + // - Input: TypeEntry pointing at the synthetic input struct's shape_fn. + // - Output: TypeEntry whose shape is a copy of the user's Output shape + // (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs). + // - For Vec outputs, ALSO register T's TypeEntry pointing at T's + // MizanType impl (so the Ref resolves in the IR). + let mut type_registrations = Vec::new(); + if has_input { + let static_ident = + format_ident!("__MIZAN_TYPE_{}", input_type_name.to_shouty_snake_case()); + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #static_ident: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: #input_type_name, + shape_fn: <#input_type_ident as ::mizan_core::MizanType>::shape, + }; + }); + } + + let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case()); + if analysis.is_vec { + let elem = analysis.vec_inner.as_ref().expect("vec_inner set"); + // userOrdersOutput → alias { list { ref "OrderOutput" } } + // The Ref name is resolved via `::type_name()`. + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: #output_type_name, + shape_fn: || ::mizan_core::NamedType::Alias( + ::mizan_core::TypeShape::List(::std::boxed::Box::new( + ::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME) + )) + ), + }; + }); + // Also register the element type itself by its own name. `TYPE_NAME` + // is an associated const, so this is usable in a static initializer. + let elem_static = element_type_static_ident(elem); + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: <#elem as ::mizan_core::MizanType>::TYPE_NAME, + shape_fn: <#elem as ::mizan_core::MizanType>::shape, + }; + }); + } else { + // Non-Vec output: copy the inner type's shape under the canonical name. + let inner_ty = &analysis.inner; + type_registrations.push(quote! { + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry { + name: #output_type_name, + shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape, + }; + }); + } + + // ─── InputParam slice (for context-builder shared-param elevation) ──── + let mut input_params = Vec::new(); + for arg in &input_args { + // Wire-level name strips the underscore prefix — see input_struct + // above for the rationale. + let name_str = arg.ident.to_string(); + let name_str = name_str.trim_start_matches('_').to_string(); + let primitive = primitive_of(&arg.ty).unwrap_or_else(|| { + // Non-primitive params don't surface in the context's `param` + // block; they participate as opaque payloads. Using `String` as + // the placeholder primitive matches Python's fallback in + // `_annotation_to_primitive`. + quote! { ::mizan_core::Primitive::String } + }); + let is_optional = unwrap_option(&arg.ty).is_some(); + let required = !is_optional; + input_params.push(quote! { + ::mizan_core::InputParam { + name: #name_str, + primitive: #primitive, + required: #required, + } + }); + } + let params_static = format_ident!("__MIZAN_FN_{}_PARAMS", fn_name.to_shouty_snake_case()); + let params_const = quote! { + const #params_static: &[::mizan_core::InputParam] = &[ + #(#input_params),* + ]; + }; + + // ─── AffectTarget / merge / context wiring ───────────────────────────── + let affects_static = format_ident!("__MIZAN_FN_{}_AFFECTS", fn_name.to_shouty_snake_case()); + let affects_entries: Vec<_> = args + .affects + .iter() + .map(|p| { + quote! { + ::mizan_core::AffectTarget::Context(<#p as ::mizan_core::ContextMarker>::NAME) + } + }) + .collect(); + let affects_const = quote! { + const #affects_static: &[::mizan_core::AffectTarget] = &[ + #(#affects_entries),* + ]; + }; + + let merge_static = format_ident!("__MIZAN_FN_{}_MERGE", fn_name.to_shouty_snake_case()); + let merge_entries: Vec<_> = args + .merge + .iter() + .map(|p| quote! { <#p as ::mizan_core::ContextMarker>::NAME }) + .collect(); + let merge_const = quote! { + const #merge_static: &[&'static str] = &[ + #(#merge_entries),* + ]; + }; + + let context_value = match &args.context { + Some(p) => quote! { ::std::option::Option::Some(<#p as ::mizan_core::ContextMarker>::NAME) }, + None => quote! { ::std::option::Option::None }, + }; + + let transport_value = if args.websocket { + quote! { ::mizan_core::Transport::Websocket } + } else { + quote! { ::mizan_core::Transport::Http } + }; + + // ─── Dispatch wrapper + FunctionSpec impl ────────────────────────────── + let inner_fn_ident = item.sig.ident.clone(); + let spec_struct = format_ident!("__MizanFn_{}", inner_fn_ident); + let spec_const = format_ident!("__MIZAN_FN_{}_SPEC", fn_name.to_shouty_snake_case()); + let register_static = + format_ident!("__MIZAN_FN_{}_REGISTER", fn_name.to_shouty_snake_case()); + + let input_type_opt = if has_input { + quote! { ::std::option::Option::Some(#input_type_name) } + } else { + quote! { ::std::option::Option::None } + }; + + let output_nullable = analysis.nullable; + let private = args.private; + + let dispatch_body = build_dispatch(&item, &input_args, has_input, &input_type_ident); + + quote! { + // Keep the user's original fn intact — the macro never rewrites the + // body, only wraps it for dispatch. + #item + + #input_struct + + #(#type_registrations)* + + #params_const + #affects_const + #merge_const + + #[allow(non_camel_case_types)] + pub struct #spec_struct; + + impl ::mizan_core::FunctionSpec for #spec_struct { + fn name(&self) -> &'static str { #fn_name } + fn camel_name(&self) -> &'static str { #camel } + fn has_input(&self) -> bool { #has_input } + fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt } + fn output_type(&self) -> &'static str { #output_type_name } + fn output_nullable(&self) -> bool { #output_nullable } + fn context(&self) -> ::std::option::Option<&'static str> { #context_value } + fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static } + fn merge(&self) -> &'static [&'static str] { #merge_static } + fn transport(&self) -> ::mizan_core::Transport { #transport_value } + fn private(&self) -> bool { #private } + fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static } + + fn dispatch<'a>( + &'a self, + req: ::mizan_core::RequestHandle<'a>, + args: ::mizan_core::__priv::serde_json::Value, + ) -> ::std::pin::Pin<::std::boxed::Box + > + ::std::marker::Send + 'a>> { + ::std::boxed::Box::pin(async move { + #dispatch_body + }) + } + } + + #[allow(non_upper_case_globals)] + static #spec_const: #spec_struct = #spec_struct; + + #[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::FUNCTIONS)] + #[linkme(crate = ::mizan_core::__priv::linkme)] + static #register_static: &dyn ::mizan_core::FunctionSpec = &#spec_const; + } +} + +fn collect_input_args(item: &ItemFn) -> syn::Result> { + let mut out = Vec::new(); + let mut iter = item.sig.inputs.iter(); + // First arg is the request handle — skip without inspection. The function + // body uses it directly; the dispatch wrapper forwards `req`. + if iter.next().is_none() { + return Err(syn::Error::new( + item.sig.span(), + "#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).", + )); + } + for arg in iter { + match arg { + FnArg::Typed(pat) => { + let ident = match &*pat.pat { + Pat::Ident(pi) => pi.ident.clone(), + _ => { + return Err(syn::Error::new_spanned( + &pat.pat, + "#[mizan] function parameters must be plain identifiers (no destructuring).", + )); + } + }; + out.push(InputArg { + ident, + ty: (*pat.ty).clone(), + }); + } + FnArg::Receiver(_) => { + return Err(syn::Error::new_spanned( + arg, + "#[mizan] functions are free functions, not methods. `self` is not allowed.", + )); + } + } + } + Ok(out) +} + +fn build_dispatch( + item: &ItemFn, + input_args: &[InputArg], + has_input: bool, + input_type_ident: &syn::Ident, +) -> TokenStream { + let inner = &item.sig.ident; + if has_input { + let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect(); + quote! { + let validated: #input_type_ident = ::mizan_core::__priv::serde_json::from_value(args) + .map_err(|e| ::mizan_core::MizanError::ValidationFailed { + message: format!("input validation failed: {e}"), + details: ::mizan_core::__priv::serde_json::Value::Null, + })?; + let result = #inner( + &req, + #( validated.#arg_names ),* + ).await; + ::mizan_core::__priv::serde_json::to_value(&result) + .map_err(|e| ::mizan_core::MizanError::InternalError( + format!("output serialization failed: {e}"), + )) + } + } else { + quote! { + let _ = args; + let result = #inner(&req).await; + ::mizan_core::__priv::serde_json::to_value(&result) + .map_err(|e| ::mizan_core::MizanError::InternalError( + format!("output serialization failed: {e}"), + )) + } + } +} + +fn element_type_static_ident(ty: &Type) -> syn::Ident { + // Derive a unique static-name for the type's registration entry. Uses + // the last path segment's identifier as the discriminator. + let last = match ty { + Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()), + _ => None, + }; + let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case(); + format_ident!("__MIZAN_TYPE_ELEM_{}", suffix) +} + diff --git a/cores/mizan-rust-macros/src/lib.rs b/cores/mizan-rust-macros/src/lib.rs new file mode 100644 index 0000000..6e2ae60 --- /dev/null +++ b/cores/mizan-rust-macros/src/lib.rs @@ -0,0 +1,58 @@ +//! Proc macros for `mizan-core`. See sibling modules for each macro's body. +//! +//! Consumer code reads: +//! ```ignore +//! use mizan_core::prelude::*; +//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally +//! +//! #[derive(Mizan, serde::Serialize, serde::Deserialize)] +//! pub struct ProfileOutput { pub user_id: i64, pub name: String } +//! +//! #[mizan::context("user")] +//! pub struct UserCtx; +//! +//! #[mizan::client(context = UserCtx)] +//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... } +//! ``` +//! +//! The function macro is named `client` to mirror Python's `@client` +//! decorator and to keep the namespace `mizan::` purely a module path — +//! `#[mizan(...)]` would collide with `mizan::context` (a module path +//! can't simultaneously be a callable macro in Rust). + +mod context; +mod derive; +mod function; +mod shape; + +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct}; + +#[proc_macro_derive(Mizan)] +pub fn derive_mizan(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + derive::expand(input).into() +} + +#[proc_macro_attribute] +pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = match context::ContextArgs::parse(attr.into()) { + Ok(a) => a, + Err(e) => return e.to_compile_error().into(), + }; + let item = parse_macro_input!(item as ItemStruct); + context::expand(args, item).into() +} + +/// The function-registration attribute macro. Used as `#[mizan::client]` +/// (no args) or `#[mizan::client(context = X, affects = Y, merge = Z, +/// websocket, private)]`. +#[proc_macro_attribute] +pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = match function::FunctionArgs::parse(attr.into()) { + Ok(a) => a, + Err(e) => return e.to_compile_error().into(), + }; + let item = parse_macro_input!(item as ItemFn); + function::expand(args, item).into() +} diff --git a/cores/mizan-rust-macros/src/shape.rs b/cores/mizan-rust-macros/src/shape.rs new file mode 100644 index 0000000..8bc9f68 --- /dev/null +++ b/cores/mizan-rust-macros/src/shape.rs @@ -0,0 +1,123 @@ +//! Lower a `syn::Type` to a TypeShape construction expression. Shared by +//! `#[derive(Mizan)]` (for struct fields) and `#[mizan(...)]` (for fn input +//! params + return-type analysis). + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{GenericArgument, PathArguments, Type, TypePath}; + +/// Result of inspecting a fn's return type. +pub struct ReturnAnalysis { + /// Inner type once `Option<...>` is unwrapped. + pub inner: Type, + /// True if the outermost wrapper is `Option<...>`. + pub nullable: bool, + /// True if `inner` is `Vec` — caller emits an alias type entry. + pub is_vec: bool, + /// When `is_vec`, this is the element type `T`. + pub vec_inner: Option, +} + +pub fn analyze_return(ty: &Type) -> ReturnAnalysis { + let (inner, nullable) = if let Some(t) = unwrap_option(ty) { + (t, true) + } else { + (ty.clone(), false) + }; + if let Some(elem) = unwrap_vec(&inner) { + ReturnAnalysis { + inner: inner.clone(), + nullable, + is_vec: true, + vec_inner: Some(elem), + } + } else { + ReturnAnalysis { + inner, + nullable, + is_vec: false, + vec_inner: None, + } + } +} + +/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]` +/// when constructing the struct field shapes. +pub fn type_shape_expr(ty: &Type) -> TokenStream { + if let Some(inner) = unwrap_option(ty) { + let inner_shape = type_shape_expr(&inner); + return quote! { + ::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape)) + }; + } + if let Some(elem) = unwrap_vec(ty) { + let inner_shape = type_shape_expr(&elem); + return quote! { + ::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape)) + }; + } + if let Some(p) = primitive_of(ty) { + return quote! { ::mizan_core::TypeShape::Primitive(#p) }; + } + // Fallback: assume a user-defined struct/enum implementing MizanType. + // The Ref name comes from `::type_name()` at runtime. + quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::type_name()) } +} + +/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a +/// known primitive scalar. +pub fn primitive_of(ty: &Type) -> Option { + let path = match ty { + Type::Path(TypePath { qself: None, path }) => path, + _ => return None, + }; + let last = path.segments.last()?; + let name = last.ident.to_string(); + match name.as_str() { + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128" + | "usize" => Some(quote! { ::mizan_core::Primitive::Integer }), + "f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }), + "bool" => Some(quote! { ::mizan_core::Primitive::Boolean }), + "String" | "str" => Some(quote! { ::mizan_core::Primitive::String }), + _ => None, + } +} + +/// If `ty` is `Option`, return `T`. Otherwise None. +pub fn unwrap_option(ty: &Type) -> Option { + let path = match ty { + Type::Path(TypePath { qself: None, path }) => path, + _ => return None, + }; + let last = path.segments.last()?; + if last.ident != "Option" { + return None; + } + extract_single_generic(&last.arguments) +} + +/// If `ty` is `Vec`, return `T`. Otherwise None. +pub fn unwrap_vec(ty: &Type) -> Option { + let path = match ty { + Type::Path(TypePath { qself: None, path }) => path, + _ => return None, + }; + let last = path.segments.last()?; + if last.ident != "Vec" { + return None; + } + extract_single_generic(&last.arguments) +} + +fn extract_single_generic(args: &PathArguments) -> Option { + let args = match args { + PathArguments::AngleBracketed(a) => a, + _ => return None, + }; + for arg in &args.args { + if let GenericArgument::Type(t) = arg { + return Some(t.clone()); + } + } + None +} diff --git a/cores/mizan-rust/Cargo.lock b/cores/mizan-rust/Cargo.lock new file mode 100644 index 0000000..a2db134 --- /dev/null +++ b/cores/mizan-rust/Cargo.lock @@ -0,0 +1,173 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mizan-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "indoc", + "linkme", + "mizan-macros", + "serde", + "serde_json", +] + +[[package]] +name = "mizan-macros" +version = "0.1.0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cores/mizan-rust/Cargo.toml b/cores/mizan-rust/Cargo.toml new file mode 100644 index 0000000..dda843e --- /dev/null +++ b/cores/mizan-rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "mizan-core" +version = "0.1.0" +edition = "2021" +description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/." +license = "MIT" + +[dependencies] +linkme = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +async-trait = "0.1" +mizan-macros = { path = "../mizan-rust-macros" } + +[dev-dependencies] +indoc = "2" diff --git a/cores/mizan-rust/src/graph_check.rs b/cores/mizan-rust/src/graph_check.rs new file mode 100644 index 0000000..2ed2e80 --- /dev/null +++ b/cores/mizan-rust/src/graph_check.rs @@ -0,0 +1,165 @@ +//! Cross-function invariant verification — fails at `build_ir()` time, which +//! runs at the codegen subprocess (`cargo run --bin export-ir`). All +//! graph-level inconsistencies surface before any client artifact is emitted. + +use crate::ir::{AffectTarget, NamedType, StructField, TypeShape}; +use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES}; + +/// Walk the registered types and find the named type's shape. Used by both +/// graph-check and runtime merge resolution. +pub(crate) fn resolve_type_shape(name: &str) -> Option { + for entry in TYPES { + if entry.name == name { + return Some((entry.shape_fn)()); + } + } + None +} + +/// Structural equality on named types. Two types are merge-compatible iff +/// they have identical shape — matches Python's `types_match_for_merge`. +pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool { + match (a, b) { + (NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb), + (NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb), + (NamedType::Enum(va), NamedType::Enum(vb)) => va == vb, + _ => false, + } +} + +fn fields_match(a: &[StructField], b: &[StructField]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b.iter()).all(|(fa, fb)| { + fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape) + }) +} + +fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool { + match (a, b) { + (TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => { + std::mem::discriminant(pa) == std::mem::discriminant(pb) + } + (TypeShape::Ref(na), TypeShape::Ref(nb)) => { + // Refs match iff the named types they reference match. + match (resolve_type_shape(na), resolve_type_shape(nb)) { + (Some(ta), Some(tb)) => types_match(&ta, &tb), + _ => na == nb, + } + } + (TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib), + (TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib), + (TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb, + (TypeShape::Union(ba), TypeShape::Union(bb)) => { + ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y)) + } + _ => false, + } +} + +/// Panic with a structured message if the registered function graph is +/// inconsistent. Called from `build_ir()`. +pub fn verify_invariants() { + check_affects_targets(); + check_merge_targets(); + check_shared_param_types(); +} + +fn check_affects_targets() { + for fn_spec in FUNCTIONS { + for affect in fn_spec.affects() { + if let AffectTarget::Context(name) = affect { + if lookup_context(name).is_none() { + panic!( + "Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \ + Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.", + fn_spec.name(), + name, + name, + ); + } + } + } + } +} + +fn check_merge_targets() { + for fn_spec in FUNCTIONS { + for merge_target in fn_spec.merge() { + let ctx_entry = match lookup_context(merge_target) { + Some(c) => c, + None => panic!( + "Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.", + fn_spec.name(), + merge_target, + ), + }; + + let mutation_output = fn_spec.output_type(); + let mutation_shape = match resolve_type_shape(mutation_output) { + Some(s) => s, + None => panic!( + "Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.", + fn_spec.name(), mutation_output, + ), + }; + let mut matches: Vec<&'static str> = Vec::new(); + for candidate in FUNCTIONS { + if candidate.context() != Some(ctx_entry.name) { + continue; + } + if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) { + if types_match(&candidate_shape, &mutation_shape) { + matches.push(candidate.name()); + } + } + } + + if matches.is_empty() { + panic!( + "Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \ + Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.", + fn_spec.name(), merge_target, mutation_output, mutation_output, + ); + } + if matches.len() > 1 { + panic!( + "Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \ + Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.", + fn_spec.name(), merge_target, matches.join(", "), mutation_output, + ); + } + } + } +} + +fn check_shared_param_types() { + for ctx in CONTEXTS { + let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)> + = std::collections::HashMap::new(); + for fn_spec in FUNCTIONS { + if fn_spec.context() != Some(ctx.name) { + continue; + } + for p in fn_spec.input_params() { + if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) { + if std::mem::discriminant(prev_primitive) + != std::mem::discriminant(&p.primitive) + { + panic!( + "Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \ + Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \ + Shared params must have one type across the whole context.", + ctx.name, p.name, + prev_fn, prev_primitive.name(), + fn_spec.name(), p.primitive.name(), + ); + } + } else { + by_name.insert(p.name, (p.primitive, fn_spec.name())); + } + } + } + } +} diff --git a/cores/mizan-rust/src/ir.rs b/cores/mizan-rust/src/ir.rs new file mode 100644 index 0000000..108ab52 --- /dev/null +++ b/cores/mizan-rust/src/ir.rs @@ -0,0 +1,93 @@ +//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1. +//! +//! The IR is the contract. Backends emit it; codegen consumes it. The Rust +//! side produces byte-equivalent KDL to the Python emitter against the same +//! function registry. + +/// A named type that appears in the IR's `type "" { ... }` section. +#[derive(Debug, Clone)] +pub enum NamedType { + /// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record. + Struct(Vec), + /// `type "X" { alias { } }` — a named wrapper around an + /// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`. + Alias(TypeShape), + /// `type "X" { enum "A" "B" ... }` — a string-literal enum. + Enum(Vec<&'static str>), +} + +/// The set of in-place type shapes referenced from struct fields, function +/// inputs/outputs, and alias bodies. +#[derive(Debug, Clone)] +pub enum TypeShape { + Primitive(Primitive), + Ref(&'static str), + List(Box), + Optional(Box), + Enum(Vec<&'static str>), + Union(Vec), +} + +#[derive(Debug, Clone, Copy)] +pub enum Primitive { + Integer, + Number, + Boolean, + String, +} + +impl Primitive { + pub fn name(self) -> &'static str { + match self { + Primitive::Integer => "integer", + Primitive::Number => "number", + Primitive::Boolean => "boolean", + Primitive::String => "string", + } + } +} + +#[derive(Debug, Clone)] +pub struct StructField { + pub name: &'static str, + pub required: bool, + pub default: Option, + pub shape: TypeShape, +} + +#[derive(Debug, Clone)] +pub enum DefaultValue { + Integer(i64), + Number(f64), + Boolean(bool), + String(&'static str), + Null, +} + +/// One descriptor of what a mutation `affects`. Mirrors Python's +/// `_normalize_affects` shape — either a named context or a named function. +#[derive(Debug, Clone)] +pub enum AffectTarget { + Context(&'static str), + Function { + name: &'static str, + context: Option<&'static str>, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + Http, + Websocket, + Both, +} + +impl Transport { + pub fn name(self) -> &'static str { + match self { + Transport::Http => "http", + Transport::Websocket => "websocket", + Transport::Both => "both", + } + } +} diff --git a/cores/mizan-rust/src/kdl.rs b/cores/mizan-rust/src/kdl.rs new file mode 100644 index 0000000..d59bd99 --- /dev/null +++ b/cores/mizan-rust/src/kdl.rs @@ -0,0 +1,397 @@ +//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`. +//! +//! The Python emitter is the spec; this is the second implementation under +//! the same contract. Any divergence is a bug here, not a contract change. + +use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape}; +use crate::registry::{CONTEXTS, FUNCTIONS, TYPES}; +use crate::traits::FunctionSpec; +use std::collections::BTreeMap; + +const INDENT: &str = " "; + +/// Escape a string for KDL — same escape set as the Python emitter. +fn kdl_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), + } + } + out.push('"'); + out +} + +fn kdl_bool(b: bool) -> &'static str { + if b { + "#true" + } else { + "#false" + } +} + +fn kdl_default(v: &DefaultValue) -> String { + match v { + DefaultValue::Null => "#null".into(), + DefaultValue::Boolean(b) => kdl_bool(*b).into(), + DefaultValue::Integer(i) => i.to_string(), + DefaultValue::Number(f) => { + // Match Python's `repr(float)` for whole-number-equal-but-float + // values: e.g. 1.0 → "1.0", not "1". + if f.fract() == 0.0 && f.is_finite() { + format!("{f:.1}") + } else { + f.to_string() + } + } + DefaultValue::String(s) => kdl_string(s), + } +} + +/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`. +pub fn snake_to_camel(name: &str) -> String { + let normalized = name.replace('.', "_").replace('-', "_"); + let mut parts = normalized.split('_'); + let mut out = String::new(); + if let Some(first) = parts.next() { + out.push_str(first); + } + for part in parts { + if part.is_empty() { + continue; + } + let mut chars = part.chars(); + if let Some(c) = chars.next() { + out.extend(c.to_uppercase()); + out.push_str(chars.as_str()); + } + } + out +} + +struct Emitter { + lines: Vec, +} + +impl Emitter { + fn new() -> Self { + Self { lines: Vec::new() } + } + + fn prefix(&self, indent: usize) -> String { + INDENT.repeat(indent) + } + + fn leaf(&mut self, indent: usize, parts: &[&str]) { + let mut line = self.prefix(indent); + line.push_str(&parts.join(" ")); + self.lines.push(line); + } + + fn open(&mut self, indent: usize, parts: &[&str]) { + let mut line = self.prefix(indent); + line.push_str(&parts.join(" ")); + line.push_str(" {"); + self.lines.push(line); + } + + fn close(&mut self, indent: usize) { + let mut line = self.prefix(indent); + line.push('}'); + self.lines.push(line); + } + + fn blank(&mut self) { + self.lines.push(String::new()); + } + + fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) { + match shape { + TypeShape::Primitive(p) => { + let name = kdl_string(p.name()); + self.leaf(indent, &["primitive", &name]); + } + TypeShape::Ref(name) => { + let n = kdl_string(name); + self.leaf(indent, &["ref", &n]); + } + TypeShape::List(inner) => { + self.open(indent, &["list"]); + self.emit_type_child(indent + 1, inner); + self.close(indent); + } + TypeShape::Optional(inner) => { + self.open(indent, &["optional"]); + self.emit_type_child(indent + 1, inner); + self.close(indent); + } + TypeShape::Enum(variants) => { + let mut parts: Vec = vec!["enum".into()]; + for v in variants { + parts.push(kdl_string(v)); + } + let line: Vec<&str> = parts.iter().map(String::as_str).collect(); + self.leaf(indent, &line); + } + TypeShape::Union(branches) => { + self.open(indent, &["union"]); + for b in branches { + self.emit_type_child(indent + 1, b); + } + self.close(indent); + } + } + } + + fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) { + let name_lit = kdl_string(name); + self.open(indent, &["type", &name_lit]); + match body { + NamedType::Struct(fields) => { + self.open(indent + 1, &["struct"]); + for field in fields { + self.emit_struct_field(indent + 2, field); + } + self.close(indent + 1); + } + NamedType::Alias(inner) => { + self.open(indent + 1, &["alias"]); + self.emit_type_child(indent + 2, inner); + self.close(indent + 1); + } + NamedType::Enum(variants) => { + let mut parts: Vec = vec!["enum".into()]; + for v in variants { + parts.push(kdl_string(v)); + } + let line: Vec<&str> = parts.iter().map(String::as_str).collect(); + self.leaf(indent + 1, &line); + } + } + self.close(indent); + } + + fn emit_struct_field(&mut self, indent: usize, field: &StructField) { + let name = kdl_string(field.name); + let mut header: Vec = vec!["field".into(), name]; + if !field.required { + header.push(format!("required={}", kdl_bool(false))); + if let Some(default) = &field.default { + header.push(format!("default={}", kdl_default(default))); + } + } + let line_parts: Vec<&str> = header.iter().map(String::as_str).collect(); + self.open(indent, &line_parts); + self.emit_type_child(indent + 1, &field.shape); + self.close(indent); + } + + fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) { + let name = kdl_string(fn_spec.name()); + self.open(indent, &["function", &name]); + + let camel = kdl_string(fn_spec.camel_name()); + self.leaf(indent + 1, &["camel", &camel]); + + self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]); + + if let Some(input_type) = fn_spec.input_type() { + let lit = kdl_string(input_type); + self.leaf(indent + 1, &["input", &lit]); + } + + let output_lit = kdl_string(fn_spec.output_type()); + self.leaf(indent + 1, &["output", &output_lit]); + + if fn_spec.output_nullable() { + self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]); + } + + let transport_lit = kdl_string(fn_spec.transport().name()); + self.leaf(indent + 1, &["transport", &transport_lit]); + + if let Some(ctx) = fn_spec.context() { + let lit = kdl_string(ctx); + self.leaf(indent + 1, &["context", &lit]); + } + + for affect in fn_spec.affects() { + // Mirror Python's behavior: only context-typed affects make it + // into the KDL `affects` leaf. Function-typed affects are + // reserved for a future IR extension. + if let crate::ir::AffectTarget::Context(name) = affect { + let lit = kdl_string(name); + self.leaf(indent + 1, &["affects", &lit]); + } + } + + for merge in fn_spec.merge() { + let lit = kdl_string(merge); + self.leaf(indent + 1, &["merge", &lit]); + } + + if fn_spec.is_form() { + self.leaf(indent + 1, &["is-form", kdl_bool(true)]); + if let Some(form_name) = fn_spec.form_name() { + let lit = kdl_string(form_name); + self.leaf(indent + 1, &["form-name", &lit]); + } + if let Some(form_role) = fn_spec.form_role() { + let lit = kdl_string(form_role); + self.leaf(indent + 1, &["form-role", &lit]); + } + } + + self.close(indent); + } + + fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) { + let name_lit = kdl_string(ctx_name); + self.open(indent, &["context", &name_lit]); + + // Function membership in registration order. + for fn_spec in members { + let lit = kdl_string(fn_spec.name()); + self.leaf(indent + 1, &["function", &lit]); + } + + // Param info — collect across every member, then emit alphabetized + // by param name to match Python. + struct ParamSlot { + primitive: Primitive, + shared_by: Vec<&'static str>, + } + let mut params: BTreeMap<&'static str, ParamSlot> = BTreeMap::new(); + for fn_spec in members { + for p in fn_spec.input_params() { + let slot = params.entry(p.name).or_insert(ParamSlot { + primitive: p.primitive, + shared_by: Vec::new(), + }); + slot.primitive = p.primitive; + slot.shared_by.push(fn_spec.name()); + } + } + + let member_count = members.len(); + for (param_name, slot) in params.iter() { + let name_lit = kdl_string(param_name); + self.open(indent + 1, &["param", &name_lit]); + let type_lit = kdl_string(slot.primitive.name()); + self.leaf(indent + 2, &["type", &type_lit]); + let required = slot.shared_by.len() == member_count; + self.leaf(indent + 2, &["required", kdl_bool(required)]); + for sharer in &slot.shared_by { + let lit = kdl_string(sharer); + self.leaf(indent + 2, &["shared-by", &lit]); + } + self.close(indent + 1); + } + + self.close(indent); + } + + fn into_string(mut self) -> String { + // Trim trailing blanks, then add a single terminating newline. + while matches!(self.lines.last(), Some(s) if s.is_empty()) { + self.lines.pop(); + } + let mut out = self.lines.join("\n"); + out.push('\n'); + out + } +} + +/// Collected typed registries view used by `build_ir`. +pub(crate) struct IrSnapshot { + pub types: BTreeMap<&'static str, NamedType>, + pub functions: Vec<&'static dyn FunctionSpec>, + pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>, +} + +impl IrSnapshot { + pub(crate) fn collect() -> Self { + // Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`. + let mut types: BTreeMap<&'static str, NamedType> = BTreeMap::new(); + for entry in TYPES { + types.insert(entry.name, (entry.shape_fn)()); + } + + // Functions: alphabetical by wire name (canonical IR ordering, + // matches the Python emitter's `sorted(functions)`). Skip `private`. + let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS + .iter() + .copied() + .filter(|f| !f.private()) + .collect(); + functions.sort_by_key(|f| f.name()); + + // Contexts: alphabetical by name (canonical IR ordering), each with + // its members sorted alphabetically too. + let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect(); + context_names.sort(); + let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new(); + for name in context_names { + let mut members: Vec<&'static dyn FunctionSpec> = functions + .iter() + .copied() + .filter(|f| f.context() == Some(name)) + .collect(); + members.sort_by_key(|f| f.name()); + if !members.is_empty() { + contexts.push((name, members)); + } + } + + Self { + types, + functions, + contexts, + } + } +} + +/// Build the Mizan IR for every registered type/function/context. Returns KDL. +pub fn build_ir() -> String { + crate::graph_check::verify_invariants(); + let snap = IrSnapshot::collect(); + let mut em = Emitter::new(); + + // Type definitions + let types_emitted = !snap.types.is_empty(); + for (name, body) in &snap.types { + em.emit_named_type(0, name, body); + } + if types_emitted { + em.blank(); + } + + // Functions + let fns_emitted = !snap.functions.is_empty(); + for fn_spec in &snap.functions { + em.emit_function(0, *fn_spec); + } + if fns_emitted { + em.blank(); + } + + // Contexts + let ctxs_emitted = !snap.contexts.is_empty(); + for (ctx_name, members) in &snap.contexts { + em.emit_context(0, ctx_name, members); + } + if ctxs_emitted { + em.blank(); + } + + // Future: channels — once channel registry lands on the Rust side. + + em.into_string() +} + diff --git a/cores/mizan-rust/src/lib.rs b/cores/mizan-rust/src/lib.rs new file mode 100644 index 0000000..ae0ab97 --- /dev/null +++ b/cores/mizan-rust/src/lib.rs @@ -0,0 +1,57 @@ +//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`. +//! +//! Three load-bearing concerns: +//! +//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent +//! KDL to the Python emitter. Both backends emit the same contract. +//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate +//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the +//! consumer crate's expansion sites. +//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` / +//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP +//! adapter calls these per request. +//! +//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at +//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`. + +pub mod graph_check; +pub mod ir; +pub mod kdl; +pub mod registry; +pub mod runtime; +pub mod traits; + +pub use ir::{ + AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, +}; +pub use kdl::{build_ir, snake_to_camel}; +pub use registry::{ + context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS, + FUNCTIONS, TYPES, +}; +pub use runtime::{ + compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError, + RequestHandle, +}; +pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; + +// Re-export proc macros so consumers depend on one crate. +pub use mizan_macros::{client, context, Mizan}; + +pub mod prelude { + pub use crate::ir::{ + AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape, + }; + pub use crate::registry::{ContextEntry, TypeEntry}; + pub use crate::runtime::{MizanError, RequestHandle}; + pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType}; + pub use mizan_macros::Mizan; +} + +/// Internal re-exports used by `mizan-macros`-generated code. Not part of +/// the public API — consumers must not depend on names under `__priv`. +#[doc(hidden)] +pub mod __priv { + pub use linkme; + pub use serde_json; +} diff --git a/cores/mizan-rust/src/registry.rs b/cores/mizan-rust/src/registry.rs new file mode 100644 index 0000000..b7f7b83 --- /dev/null +++ b/cores/mizan-rust/src/registry.rs @@ -0,0 +1,47 @@ +//! Compile-time-populated registries, distributed across the consuming crate's +//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]` +//! statics that land here at link time. + +use crate::ir::NamedType; +use crate::traits::FunctionSpec; +use linkme::distributed_slice; + +/// One named-type registration. Emitted by `#[derive(Mizan)]`. +pub struct TypeEntry { + pub name: &'static str, + pub shape_fn: fn() -> NamedType, +} + +/// One context-marker registration. Emitted by `#[mizan::context]`. +pub struct ContextEntry { + pub name: &'static str, +} + +#[distributed_slice] +pub static TYPES: [TypeEntry] = [..]; + +#[distributed_slice] +pub static CONTEXTS: [ContextEntry] = [..]; + +#[distributed_slice] +pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..]; + +/// Find a registered function by wire name. Used by the HTTP adapter. +pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> { + FUNCTIONS.iter().copied().find(|f| f.name() == name) +} + +/// Find a registered context by name. Used by graph_check. +pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> { + CONTEXTS.iter().find(|c| c.name == name) +} + +/// All functions that declare a given context as their `context` membership. +/// Order matches `FUNCTIONS` iteration order — i.e., registration order. +pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> { + FUNCTIONS + .iter() + .copied() + .filter(|f| f.context() == Some(ctx_name)) + .collect() +} diff --git a/cores/mizan-rust/src/runtime.rs b/cores/mizan-rust/src/runtime.rs new file mode 100644 index 0000000..de084a9 --- /dev/null +++ b/cores/mizan-rust/src/runtime.rs @@ -0,0 +1,252 @@ +//! Runtime helpers — error envelope, request handle, invalidation/merge +//! resolution. Ports `compute_invalidation` / `compute_merges` / +//! `_resolve_merge_slot` / `_scoped_params` from +//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`. + +use crate::registry::context_members; +use crate::traits::FunctionSpec; +use serde_json::Value; +use std::any::Any; + +/// Type-erased handle to the framework's request object. The HTTP adapter +/// stuffs its native `Request` here; user code casts back via the adapter's +/// helper types. +#[derive(Clone)] +pub struct RequestHandle<'a> { + pub inner: &'a (dyn Any + Send + Sync), +} + +impl<'a> RequestHandle<'a> { + pub fn new(req: &'a T) -> Self { + Self { inner: req } + } + + pub fn downcast(&self) -> Option<&'a T> { + self.inner.downcast_ref::() + } +} + +/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum. +#[derive(Debug, Clone)] +pub enum MizanError { + NotFound(String), + BadRequest(String), + ValidationFailed { + message: String, + details: Value, + }, + Unauthorized(String), + Forbidden(String), + NotImplementedYet(String), + InternalError(String), +} + +impl MizanError { + pub fn code(&self) -> &'static str { + match self { + MizanError::NotFound(_) => "NOT_FOUND", + MizanError::BadRequest(_) => "BAD_REQUEST", + MizanError::ValidationFailed { .. } => "VALIDATION_FAILED", + MizanError::Unauthorized(_) => "UNAUTHORIZED", + MizanError::Forbidden(_) => "FORBIDDEN", + MizanError::NotImplementedYet(_) => "NOT_IMPLEMENTED", + MizanError::InternalError(_) => "INTERNAL_ERROR", + } + } + + pub fn message(&self) -> &str { + match self { + MizanError::NotFound(m) + | MizanError::BadRequest(m) + | MizanError::Unauthorized(m) + | MizanError::Forbidden(m) + | MizanError::NotImplementedYet(m) + | MizanError::InternalError(m) => m, + MizanError::ValidationFailed { message, .. } => message, + } + } + + pub fn http_status(&self) -> u16 { + match self { + MizanError::NotFound(_) => 404, + MizanError::BadRequest(_) => 400, + MizanError::ValidationFailed { .. } => 422, + MizanError::Unauthorized(_) => 401, + MizanError::Forbidden(_) => 403, + MizanError::NotImplementedYet(_) => 501, + MizanError::InternalError(_) => 500, + } + } + + /// JSON envelope shape consumers see on the wire. + pub fn to_json(&self) -> Value { + let mut body = serde_json::Map::new(); + body.insert("code".into(), Value::String(self.code().into())); + body.insert("message".into(), Value::String(self.message().into())); + if let MizanError::ValidationFailed { details, .. } = self { + body.insert("details".into(), details.clone()); + } + Value::Object({ + let mut env = serde_json::Map::new(); + env.insert("error".into(), Value::Object(body)); + env + }) + } +} + +/// One entry in the response's `invalidate` array. +#[derive(Debug, Clone)] +pub enum InvalidationTarget { + /// A whole context is invalidated. + Context(String), + /// A context, scoped to specific param values. + ScopedContext { + context: String, + params: serde_json::Map, + }, + /// A specific function output is invalidated. + Function(String), +} + +impl InvalidationTarget { + pub fn to_json(&self) -> Value { + match self { + InvalidationTarget::Context(name) => Value::String(name.clone()), + InvalidationTarget::ScopedContext { context, params } => { + let mut m = serde_json::Map::new(); + m.insert("context".into(), Value::String(context.clone())); + m.insert("params".into(), Value::Object(params.clone())); + Value::Object(m) + } + InvalidationTarget::Function(name) => { + let mut m = serde_json::Map::new(); + m.insert("function".into(), Value::String(name.clone())); + Value::Object(m) + } + } + } +} + +/// One entry in the response's `merge` array. Server-resolved slot — the +/// kernel writes the value into `bundle[slot]` directly. +#[derive(Debug, Clone)] +pub struct MergeEntry { + pub context: String, + pub slot: String, + pub value: Value, + pub params: Option>, +} + +impl MergeEntry { + pub fn to_json(&self) -> Value { + let mut m = serde_json::Map::new(); + m.insert("context".into(), Value::String(self.context.clone())); + m.insert("slot".into(), Value::String(self.slot.clone())); + m.insert("value".into(), self.value.clone()); + if let Some(params) = &self.params { + m.insert("params".into(), Value::Object(params.clone())); + } + Value::Object(m) + } +} + +/// Build the `invalidate` list from a function's `affects` metadata, +/// auto-scoping when arg names match context params. +pub fn compute_invalidation( + fn_spec: &dyn FunctionSpec, + args: &serde_json::Map, +) -> Vec { + fn_spec + .affects() + .iter() + .map(|target| match target { + crate::ir::AffectTarget::Context(name) => { + let scoped = scoped_params(name, args); + if scoped.is_empty() { + InvalidationTarget::Context((*name).into()) + } else { + InvalidationTarget::ScopedContext { + context: (*name).into(), + params: scoped, + } + } + } + crate::ir::AffectTarget::Function { name, .. } => { + InvalidationTarget::Function((*name).into()) + } + }) + .collect() +} + +/// Build the `merge` list from a function's `merge` metadata. Each entry +/// names the slot inside the context bundle the return value lands in. +pub fn compute_merges( + fn_spec: &dyn FunctionSpec, + args: &serde_json::Map, + result: &Value, +) -> Vec { + let targets = fn_spec.merge(); + if targets.is_empty() { + return Vec::new(); + } + let mutation_output = fn_spec.output_type(); + let mut out = Vec::new(); + for ctx_name in targets { + let slot = match resolve_merge_slot(ctx_name, mutation_output) { + Some(s) => s, + None => continue, + }; + let scoped = scoped_params(ctx_name, args); + out.push(MergeEntry { + context: (*ctx_name).into(), + slot, + value: result.clone(), + params: if scoped.is_empty() { + None + } else { + Some(scoped) + }, + }); + } + out +} + +/// Find the unique function-name slot whose Output type matches the +/// mutation's Output type. Matches Python's `types_match_for_merge` — +/// structural shape comparison, not name comparison. Returns None on no +/// match or ambiguous match. +fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option { + let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?; + let mut matches: Vec<&'static str> = Vec::new(); + for fn_spec in context_members(context_name) { + if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type()) + { + if crate::graph_check::types_match(&candidate_shape, &mutation_shape) { + matches.push(fn_spec.name()); + } + } + } + if matches.len() == 1 { + Some(matches[0].into()) + } else { + None + } +} + +/// Match input args against the context's declared Input field names. +fn scoped_params( + context_name: &str, + args: &serde_json::Map, +) -> serde_json::Map { + let mut declared: std::collections::HashSet<&'static str> = std::collections::HashSet::new(); + for fn_spec in context_members(context_name) { + for p in fn_spec.input_params() { + declared.insert(p.name); + } + } + args.iter() + .filter(|(k, _)| declared.contains(k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + diff --git a/cores/mizan-rust/src/traits.rs b/cores/mizan-rust/src/traits.rs new file mode 100644 index 0000000..38f8b65 --- /dev/null +++ b/cores/mizan-rust/src/traits.rs @@ -0,0 +1,92 @@ +//! Surface traits the proc macros implement. + +use crate::ir::{AffectTarget, NamedType, Transport}; +use crate::runtime::{MizanError, RequestHandle}; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; + +/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`. +/// +/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static` +/// initializers — TypeEntry's `name` field reads it directly without an +/// init-time function call. +pub trait MizanType { + const TYPE_NAME: &'static str; + fn shape() -> NamedType; + + fn type_name() -> &'static str { + Self::TYPE_NAME + } +} + +/// A marker type for a Mizan context. Generated by `#[mizan::context]`. +pub trait ContextMarker { + const NAME: &'static str; +} + +/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns. +/// +/// Everything here is plain data except `dispatch`, which is the type-erased +/// runtime entry point used by the HTTP adapter. +pub trait FunctionSpec: Send + Sync { + fn name(&self) -> &'static str; + fn camel_name(&self) -> &'static str; + fn has_input(&self) -> bool; + fn input_type(&self) -> Option<&'static str>; + fn output_type(&self) -> &'static str; + fn output_nullable(&self) -> bool { + false + } + fn context(&self) -> Option<&'static str> { + None + } + fn affects(&self) -> &'static [AffectTarget] { + &[] + } + fn merge(&self) -> &'static [&'static str] { + &[] + } + fn transport(&self) -> Transport { + Transport::Http + } + fn private(&self) -> bool { + false + } + fn is_form(&self) -> bool { + false + } + fn form_name(&self) -> Option<&'static str> { + None + } + fn form_role(&self) -> Option<&'static str> { + None + } + + /// Field-shape description of this function's Input parameters, used by + /// the context builder to compute shared-param elevation. Empty when + /// `has_input()` is false. + fn input_params(&self) -> &'static [InputParam] { + &[] + } + + /// Type-erased dispatch. The HTTP adapter calls this with deserialized + /// JSON arguments; the macro-generated impl deserializes into the + /// function's typed input, awaits the body, and serializes the result. + fn dispatch<'a>( + &'a self, + req: RequestHandle<'a>, + args: Value, + ) -> Pin> + Send + 'a>>; +} + +/// One parameter of a function's synthesized Input. The macro emits a static +/// slice of these so the context builder can find shared params across +/// context members and produce the `context { param ... shared-by ... }` +/// section of the IR. +#[derive(Debug, Clone, Copy)] +pub struct InputParam { + pub name: &'static str, + pub primitive: crate::ir::Primitive, + pub required: bool, +} diff --git a/cores/mizan-rust/tests/afi_parity.rs b/cores/mizan-rust/tests/afi_parity.rs new file mode 100644 index 0000000..a2ae42f --- /dev/null +++ b/cores/mizan-rust/tests/afi_parity.rs @@ -0,0 +1,129 @@ +//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros) +//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical +//! Python-emitted reference). +//! +//! This is the Phase-2 verifier — the AFI fixture is authored against the +//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] / +//! #[mizan::client]`), not hand-built static specs. + +use mizan_core as mizan; +use mizan_core::prelude::*; +use mizan_core::RequestHandle; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +// ─── Output / shared types ────────────────────────────────────────────────── + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct EchoOutput { + pub message: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct WhoamiOutput { + pub email: String, + pub authenticated: bool, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct ProfileOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct OrderOutput { + pub id: i64, + pub user_id: i64, + pub total: i64, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct StatusOutput { + pub ok: bool, +} + +#[mizan::context("user")] +pub struct UserCtx; + +// ─── Fixture functions (mirroring tests/afi/fixture.py) ──────────────────── + +#[mizan::client] +pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput { + EchoOutput { + message: format!("echo: {text}"), + } +} + +#[mizan::client] +pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput { + WhoamiOutput { + email: "anon@example.com".into(), + authenticated: false, + } +} + +#[mizan::client(context = UserCtx)] +pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { + ProfileOutput { + user_id, + name: "placeholder".into(), + } +} + +#[mizan::client(context = UserCtx)] +pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec { + vec![] +} + +#[mizan::client(affects = UserCtx)] +pub async fn update_profile( + _req: &RequestHandle<'_>, + _user_id: i64, + _name: String, +) -> StatusOutput { + StatusOutput { ok: true } +} + +#[mizan::client] +pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option { + None +} + +#[mizan::client(merge = UserCtx)] +pub async fn rename_user( + _req: &RequestHandle<'_>, + user_id: i64, + name: String, +) -> ProfileOutput { + ProfileOutput { user_id, name } +} + +// ─── The byte-equivalence test ────────────────────────────────────────────── + +fn canonical_kdl_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl") +} + +#[test] +fn build_ir_matches_canonical_afi_kdl() { + let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL"); + let actual = mizan_core::build_ir(); + + if actual != expected { + for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + if a != b { + panic!( + "KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "KDL diverges in length: actual_len={} expected_len={}", + actual.len(), + expected.len(), + ); + } +} diff --git a/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl b/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl index 4a29621..ec03e95 100644 --- a/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl +++ b/protocol/mizan-codegen/tests/fixtures/afi_ir.kdl @@ -128,36 +128,6 @@ function "echo" { output "echoOutput" transport "http" } -function "whoami" { - camel "whoami" - has-input #false - output "whoamiOutput" - transport "http" -} -function "user_profile" { - camel "userProfile" - has-input #true - input "userProfileInput" - output "userProfileOutput" - transport "http" - context "user" -} -function "user_orders" { - camel "userOrders" - has-input #true - input "userOrdersInput" - output "userOrdersOutput" - transport "http" - context "user" -} -function "update_profile" { - camel "updateProfile" - has-input #true - input "updateProfileInput" - output "updateProfileOutput" - transport "http" - affects "user" -} function "find_user" { camel "findUser" has-input #true @@ -174,14 +144,44 @@ function "rename_user" { transport "http" merge "user" } +function "update_profile" { + camel "updateProfile" + has-input #true + input "updateProfileInput" + output "updateProfileOutput" + transport "http" + affects "user" +} +function "user_orders" { + camel "userOrders" + has-input #true + input "userOrdersInput" + output "userOrdersOutput" + transport "http" + context "user" +} +function "user_profile" { + camel "userProfile" + has-input #true + input "userProfileInput" + output "userProfileOutput" + transport "http" + context "user" +} +function "whoami" { + camel "whoami" + has-input #false + output "whoamiOutput" + transport "http" +} context "user" { - function "user_profile" function "user_orders" + function "user_profile" param "user_id" { type "integer" required #true - shared-by "user_profile" shared-by "user_orders" + shared-by "user_profile" } } diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/python/client.py b/protocol/mizan-codegen/tests/fixtures/baselines/python/client.py index 45d29a8..225b867 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/python/client.py +++ b/protocol/mizan-codegen/tests/fixtures/baselines/python/client.py @@ -36,14 +36,6 @@ class MizanClient: raw = self._inner.call("echo", args.model_dump()) return EchoOutput(**raw) - def call_whoami(self) -> WhoamiOutput: - raw = self._inner.call("whoami", {}) - return WhoamiOutput(**raw) - - def call_update_profile(self, args: UpdateProfileInput) -> UpdateProfileOutput: - raw = self._inner.call("update_profile", args.model_dump()) - return UpdateProfileOutput(**raw) - def call_find_user(self, args: FindUserInput) -> FindUserOutput | None: raw = self._inner.call("find_user", args.model_dump()) return FindUserOutput(**raw) if raw is not None else None @@ -52,6 +44,14 @@ class MizanClient: raw = self._inner.call("rename_user", args.model_dump()) return RenameUserOutput(**raw) + def call_update_profile(self, args: UpdateProfileInput) -> UpdateProfileOutput: + raw = self._inner.call("update_profile", args.model_dump()) + return UpdateProfileOutput(**raw) + + def call_whoami(self) -> WhoamiOutput: + raw = self._inner.call("whoami", {}) + return WhoamiOutput(**raw) + def invalidate(self, context: str) -> None: self._inner.invalidate(context) @@ -63,5 +63,5 @@ class MizanClient: class UserContextData(BaseModel): """Bundled return of fetch_user_context.""" - user_profile: UserProfileOutput user_orders: UserOrdersOutput + user_profile: UserProfileOutput diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts index 24ababc..0f53bb2 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/contexts/user.ts @@ -2,11 +2,11 @@ import { mizanFetch } from '@mizan/base' -import type { userProfileOutput, userOrdersOutput } from '../types' +import type { userOrdersOutput, userProfileOutput } from '../types' export interface UserContextData { - user_profile: userProfileOutput user_orders: userOrdersOutput + user_profile: userProfileOutput } export interface UserContextParams { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts index ae9cb24..313762c 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/index.ts @@ -5,10 +5,10 @@ export * from './types' export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user' export { callEcho } from './functions/echo' -export { callWhoami } from './functions/whoami' -export { callUpdateProfile } from './mutations/updateProfile' export { callFindUser } from './functions/findUser' export { callRenameUser } from './functions/renameUser' +export { callUpdateProfile } from './mutations/updateProfile' +export { callWhoami } from './functions/whoami' // Stage 2 framework adapter export * from './react' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx b/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx index 6fc7c64..83a59e6 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx +++ b/protocol/mizan-codegen/tests/fixtures/baselines/react/react.tsx @@ -22,7 +22,7 @@ import { type ContextState, } from '@mizan/base' -import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser, type userProfileOutput, type userOrdersOutput } from './index' +import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami, type userOrdersOutput, type userProfileOutput } from './index' // Internal — runs inside a Provider, registers with the kernel exactly once. function useContextSubscription( @@ -89,14 +89,14 @@ export function useUserContext(): ContextState { return ctx } -export function useUserProfile(): userProfileOutput | null { - return useUserContext().data?.user_profile ?? null -} - export function useUserOrders(): userOrdersOutput | null { return useUserContext().data?.user_orders ?? null } +export function useUserProfile(): userProfileOutput | null { + return useUserContext().data?.user_profile ?? null +} + export function useUpdateProfile() { return useMutation[0], Awaited>>(callUpdateProfile) } @@ -105,10 +105,6 @@ export function useEcho() { return useMutation[0], Awaited>>(callEcho) } -export function useWhoami() { - return useMutation>>(() => callWhoami() as any) -} - export function useFindUser() { return useMutation[0], Awaited>>(callFindUser) } @@ -117,6 +113,10 @@ export function useRenameUser() { return useMutation[0], Awaited>>(callRenameUser) } +export function useWhoami() { + return useMutation>>(() => callWhoami() as any) +} + // ── MizanContext root provider ── export interface MizanContextProps { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs index c9d1cdb..ebdffb0 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs +++ b/protocol/mizan-codegen/tests/fixtures/baselines/rust/src/contexts/user.rs @@ -5,12 +5,12 @@ use serde_json::Value; use mizan_rust::{MizanClient, MizanError}; -use crate::types::{UserProfileOutput, UserOrdersOutput}; +use crate::types::{UserOrdersOutput, UserProfileOutput}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserContextData { - pub user_profile: UserProfileOutput, pub user_orders: UserOrdersOutput, + pub user_profile: UserProfileOutput, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts index 24ababc..0f53bb2 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/contexts/user.ts @@ -2,11 +2,11 @@ import { mizanFetch } from '@mizan/base' -import type { userProfileOutput, userOrdersOutput } from '../types' +import type { userOrdersOutput, userProfileOutput } from '../types' export interface UserContextData { - user_profile: userProfileOutput user_orders: userOrdersOutput + user_profile: userProfileOutput } export interface UserContextParams { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts index 25059d6..e025d1f 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/stage1/index.ts @@ -5,7 +5,7 @@ export * from './types' export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user' export { callEcho } from './functions/echo' -export { callWhoami } from './functions/whoami' -export { callUpdateProfile } from './mutations/updateProfile' export { callFindUser } from './functions/findUser' export { callRenameUser } from './functions/renameUser' +export { callUpdateProfile } from './mutations/updateProfile' +export { callWhoami } from './functions/whoami' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts index 24ababc..0f53bb2 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/contexts/user.ts @@ -2,11 +2,11 @@ import { mizanFetch } from '@mizan/base' -import type { userProfileOutput, userOrdersOutput } from '../types' +import type { userOrdersOutput, userProfileOutput } from '../types' export interface UserContextData { - user_profile: userProfileOutput user_orders: userOrdersOutput + user_profile: userProfileOutput } export interface UserContextParams { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts index 9a814ce..2a288ab 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/index.ts @@ -5,10 +5,10 @@ export * from './types' export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user' export { callEcho } from './functions/echo' -export { callWhoami } from './functions/whoami' -export { callUpdateProfile } from './mutations/updateProfile' export { callFindUser } from './functions/findUser' export { callRenameUser } from './functions/renameUser' +export { callUpdateProfile } from './mutations/updateProfile' +export { callWhoami } from './functions/whoami' // Stage 2 framework adapter export * from './svelte' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts index f0b667d..04d3b4f 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/svelte/svelte.ts @@ -3,7 +3,7 @@ import { readable, type Readable } from 'svelte/store' import { registerContext, type ContextState } from '@mizan/base' -import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser } from '../index' +import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index' export function createUserContext(params: UserContextParams) { const store = readable>( @@ -21,9 +21,9 @@ export function createUserContext(params: UserContextParams) { export { callUpdateProfile } from '../index' export { callEcho } from '../index' -export { callWhoami } from '../index' export { callFindUser } from '../index' export { callRenameUser } from '../index' +export { callWhoami } from '../index' export type { ContextState } from '@mizan/base' export { configure, initSession, MizanError } from '@mizan/base' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts index 24ababc..0f53bb2 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/contexts/user.ts @@ -2,11 +2,11 @@ import { mizanFetch } from '@mizan/base' -import type { userProfileOutput, userOrdersOutput } from '../types' +import type { userOrdersOutput, userProfileOutput } from '../types' export interface UserContextData { - user_profile: userProfileOutput user_orders: userOrdersOutput + user_profile: userProfileOutput } export interface UserContextParams { diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts index 7c0f81b..4b48acf 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/index.ts @@ -5,10 +5,10 @@ export * from './types' export { fetchUserContext, type UserContextData, type UserContextParams } from './contexts/user' export { callEcho } from './functions/echo' -export { callWhoami } from './functions/whoami' -export { callUpdateProfile } from './mutations/updateProfile' export { callFindUser } from './functions/findUser' export { callRenameUser } from './functions/renameUser' +export { callUpdateProfile } from './mutations/updateProfile' +export { callWhoami } from './functions/whoami' // Stage 2 framework adapter export * from './vue' diff --git a/protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts b/protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts index f9fe057..0d6d27a 100644 --- a/protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts +++ b/protocol/mizan-codegen/tests/fixtures/baselines/vue/vue.ts @@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue' import { registerContext, type ContextState } from '@mizan/base' -import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser } from '../index' +import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callFindUser, callRenameUser, callWhoami } from '../index' export function useUserContext(params: UserContextParams) { const state = ref>({ data: null, status: 'idle', error: null }) @@ -25,8 +25,8 @@ export function useUserContext(params: UserContextParams) { return { state, - userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef, userOrders: computed(() => state.value.data?.user_orders ?? null) as ComputedRef, + userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef, loading: computed(() => state.value.status === 'loading'), error: computed(() => state.value.error), } @@ -56,18 +56,6 @@ export function useEcho() { return { mutate, isPending, error } } -export function useWhoami() { - const isPending = ref(false) - const error = ref(null) - async function mutate() { - isPending.value = true; error.value = null - try { return await callWhoami() } - catch (e) { error.value = e as Error; throw e } - finally { isPending.value = false } - } - return { mutate, isPending, error } -} - export function useFindUser() { const isPending = ref(false) const error = ref(null) @@ -92,5 +80,17 @@ export function useRenameUser() { return { mutate, isPending, error } } +export function useWhoami() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callWhoami() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + export type { ContextState } from '@mizan/base' export { configure, initSession, MizanError } from '@mizan/base' diff --git a/tests/afi/django_app/project/settings.py b/tests/afi/django_app/project/settings.py index 8d94bf7..f09aa16 100644 --- a/tests/afi/django_app/project/settings.py +++ b/tests/afi/django_app/project/settings.py @@ -1,5 +1,8 @@ """Minimal Django settings for the AFI conformance fixture.""" +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = "afi-conformance-test-only" DEBUG = True ALLOWED_HOSTS = ["*"] diff --git a/tests/afi/rust_app/Cargo.lock b/tests/afi/rust_app/Cargo.lock new file mode 100644 index 0000000..52d5ef5 --- /dev/null +++ b/tests/afi/rust_app/Cargo.lock @@ -0,0 +1,679 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "afi_rust_app" +version = "0.0.0" +dependencies = [ + "axum", + "mizan-axum", + "mizan-core", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "mizan-axum" +version = "0.1.0" +dependencies = [ + "axum", + "mizan-core", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", +] + +[[package]] +name = "mizan-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "linkme", + "mizan-macros", + "serde", + "serde_json", +] + +[[package]] +name = "mizan-macros" +version = "0.1.0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/afi/rust_app/Cargo.toml b/tests/afi/rust_app/Cargo.toml new file mode 100644 index 0000000..95d590e --- /dev/null +++ b/tests/afi/rust_app/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "afi_rust_app" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +mizan-core = { path = "../../../cores/mizan-rust" } +mizan-axum = { path = "../../../backends/mizan-rust-axum" } +axum = "0.7" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[[bin]] +name = "server" +path = "src/bin/server.rs" + +[[bin]] +name = "export-ir" +path = "src/bin/export_ir.rs" diff --git a/tests/afi/rust_app/src/bin/export_ir.rs b/tests/afi/rust_app/src/bin/export_ir.rs new file mode 100644 index 0000000..e158645 --- /dev/null +++ b/tests/afi/rust_app/src/bin/export_ir.rs @@ -0,0 +1,10 @@ +//! Emit the AFI fixture's Mizan IR (KDL) to stdout. The codegen subprocess +//! consumes this; the three-way codegen-parity test asserts it equals what +//! Django and FastAPI emit. + +fn main() { + // The fixture is registered via the `afi_rust_app` library crate at + // link time — referencing any symbol keeps the linkme statics alive. + let _ = afi_rust_app::echo; + print!("{}", mizan_core::build_ir()); +} diff --git a/tests/afi/rust_app/src/bin/server.rs b/tests/afi/rust_app/src/bin/server.rs new file mode 100644 index 0000000..f64dff0 --- /dev/null +++ b/tests/afi/rust_app/src/bin/server.rs @@ -0,0 +1,25 @@ +//! Serve the AFI fixture under axum on `PORT` env var (or 8765 default). +//! Used by the wire-parity test as the third-backend probe target. + +use axum::Router; + +#[tokio::main] +async fn main() { + // Keep the fixture's linkme statics alive by touching one of its + // symbols (the bin would otherwise dead-strip the library crate). + let _ = afi_rust_app::echo; + + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8765); + + let app = Router::new().nest("/api/mizan", mizan_axum::router()); + + let bind = format!("127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind(&bind) + .await + .unwrap_or_else(|e| panic!("bind {bind}: {e}")); + eprintln!("afi_rust_app listening on http://{bind}"); + axum::serve(listener, app).await.unwrap(); +} diff --git a/tests/afi/rust_app/src/lib.rs b/tests/afi/rust_app/src/lib.rs new file mode 100644 index 0000000..1ba0040 --- /dev/null +++ b/tests/afi/rust_app/src/lib.rs @@ -0,0 +1,94 @@ +//! AFI fixture — Rust port of `tests/afi/fixture.py`. +//! +//! Same 7 functions, same 5 shared types, same context+affects+merge graph. +//! The KDL emitted by `build_ir()` against this registry is byte-identical +//! to the canonical Python-emitted `protocol/mizan-codegen/tests/fixtures/ +//! afi_ir.kdl` — gated by the three-way codegen-parity test. + +use mizan_core as mizan; +use mizan_core::prelude::*; +use mizan_core::RequestHandle; +use serde::{Deserialize, Serialize}; + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct EchoOutput { + pub message: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct WhoamiOutput { + pub email: String, + pub authenticated: bool, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct ProfileOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct OrderOutput { + pub id: i64, + pub user_id: i64, + pub total: i64, +} + +#[derive(Mizan, Serialize, Deserialize, Debug, Clone)] +pub struct StatusOutput { + pub ok: bool, +} + +#[mizan::context("user")] +pub struct UserCtx; + +#[mizan::client] +pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput { + EchoOutput { + message: format!("echo: {text}"), + } +} + +#[mizan::client] +pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput { + WhoamiOutput { + email: "anon@example.com".into(), + authenticated: false, + } +} + +#[mizan::client(context = UserCtx)] +pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { + ProfileOutput { + user_id, + name: "placeholder".into(), + } +} + +#[mizan::client(context = UserCtx)] +pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec { + vec![] +} + +#[mizan::client(affects = UserCtx)] +pub async fn update_profile( + _req: &RequestHandle<'_>, + _user_id: i64, + _name: String, +) -> StatusOutput { + StatusOutput { ok: true } +} + +#[mizan::client] +pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option { + None +} + +#[mizan::client(merge = UserCtx)] +pub async fn rename_user( + _req: &RequestHandle<'_>, + user_id: i64, + name: String, +) -> ProfileOutput { + ProfileOutput { user_id, name } +} diff --git a/tests/afi/test_codegen_parity.py b/tests/afi/test_codegen_parity.py index 52c519d..1fa8219 100644 --- a/tests/afi/test_codegen_parity.py +++ b/tests/afi/test_codegen_parity.py @@ -1,14 +1,14 @@ """ -AFI conformance — same @client fixture, same Mizan IR (KDL), both adapters. +AFI conformance — same fixture, same Mizan IR (KDL), all three adapters. -Gates that mizan-django and mizan-fastapi emit byte-equivalent IR -for the same registered functions. If this passes, the codegen -produces identical TypeScript output regardless of backend -(codegen is deterministic over IR input). +Gates that mizan-django, mizan-fastapi, and the Rust mizan-axum backend +emit byte-equivalent KDL for the same registered functions. The IR is the +contract; whatever language wrote the backend, the wire/codegen-facing +artifact is identical. Substrate-level gate, not e2e. Catches adapter symmetry problems — -type-introspection divergence, ordering non-determinism — without -running a real frontend or backend. +type-introspection divergence, ordering non-determinism — across all +backends in one place. """ from __future__ import annotations @@ -23,6 +23,7 @@ import pytest HERE = Path(__file__).parent DJANGO_MANAGE = HERE / "django_app" / "manage.py" +RUST_APP_DIR = HERE / "rust_app" def _fetch_django_ir() -> str: @@ -56,17 +57,60 @@ def _fetch_fastapi_ir() -> str: sys.path.remove(str(HERE)) -@pytest.fixture(scope="module") -def ir_pair() -> tuple[str, str]: - return _fetch_django_ir(), _fetch_fastapi_ir() - - -class IRParityTests: - """The Mizan IR is the contract — both backends must emit the same KDL.""" - - def test_ir_bytes_match(self, ir_pair): - django, fastapi = ir_pair - assert django == fastapi, ( - "Django and FastAPI emit divergent Mizan IR for the same " - "registered functions. Substrate gate is now red." +def _fetch_rust_ir() -> str: + """Spawn the Rust `export-ir` bin and capture stdout.""" + result = subprocess.run( + [ + "cargo", "run", + "--quiet", "--release", + "--manifest-path", str(RUST_APP_DIR / "Cargo.toml"), + "--bin", "export-ir", + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.fail( + f"Rust export-ir failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}", ) + return result.stdout + + +@pytest.fixture(scope="module") +def fastapi_ir() -> str: + return _fetch_fastapi_ir() + + +@pytest.fixture(scope="module") +def rust_ir() -> str: + return _fetch_rust_ir() + + +@pytest.fixture(scope="module") +def django_ir() -> str: + return _fetch_django_ir() + + +def test_fastapi_matches_rust(fastapi_ir: str, rust_ir: str) -> None: + """FastAPI ≡ Rust. The Mizan IR is the contract across languages.""" + assert fastapi_ir == rust_ir, ( + "FastAPI and Rust emit divergent Mizan IR for the same registered " + "functions. Substrate gate is red." + ) + + +def test_django_matches_fastapi(django_ir: str, fastapi_ir: str) -> None: + """Django ≡ FastAPI.""" + assert django_ir == fastapi_ir, ( + "Django and FastAPI emit divergent Mizan IR for the same " + "registered functions. Substrate gate is red." + ) + + +def test_all_three_match(django_ir: str, fastapi_ir: str, rust_ir: str) -> None: + """All three backends emit byte-identical KDL.""" + assert django_ir == fastapi_ir == rust_ir, ( + "Three-way IR divergence — see test_django_matches_fastapi and " + "test_fastapi_matches_rust for which pair drifts." + ) diff --git a/tests/rust/run_wire_parity.py b/tests/rust/run_wire_parity.py index d336242..6f15ac2 100644 --- a/tests/rust/run_wire_parity.py +++ b/tests/rust/run_wire_parity.py @@ -1,13 +1,20 @@ -"""Drive the wire-parity check end-to-end. +"""Three-way wire-parity check. -1. Boot the FastAPI fixture app via uvicorn on a free port. -2. Poll /openapi.json until the server is up. -3. Run the Rust `drive_kernel` binary (raw kernel calls) against it. -4. Run the Rust `drive_emitted` binary (typed codegen functions) against - the same server. -5. Tear the server down. +For each backend (FastAPI, Rust axum): + 1. Boot the fixture server on a free port. + 2. Poll /api/mizan/session/ until the server responds. + 3. Run the Rust `drive_kernel` binary (raw kernel calls) against it. + 4. Run the Rust `drive_emitted` binary (typed codegen functions) against it. + 5. Tear the server down. -Either non-zero driver exit propagates as the script's exit code. +Any non-zero driver exit propagates as the script's exit code. Adding the +Rust backend here proves that mizan-axum honors the same wire contract as +mizan-fastapi — same JSON shapes, same invalidate/merge semantics — beyond +the static IR equivalence the codegen-parity test gates. + +Readiness probe is `/api/mizan/session/` (Mizan-protocol-shaped) rather +than `/openapi.json` (FastAPI-feature-shaped) so the harness reads the +same surface across backends. """ from __future__ import annotations @@ -25,6 +32,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] AFI_DIR = REPO_ROOT / "tests" / "afi" RUST_DIR = REPO_ROOT / "tests" / "rust" +RUST_APP_DIR = AFI_DIR / "rust_app" BOOT_TIMEOUT_S = 15.0 POLL_INTERVAL_S = 0.25 @@ -35,24 +43,22 @@ def pick_free_port() -> int: return s.getsockname()[1] -def wait_for_server(port: int, timeout_s: float) -> bool: +def wait_for_server(port: int, timeout_s: float, label: str) -> bool: deadline = time.monotonic() + timeout_s - url = f"http://127.0.0.1:{port}/openapi.json" + url = f"http://127.0.0.1:{port}/api/mizan/session/" while time.monotonic() < deadline: try: with urllib.request.urlopen(url, timeout=1.0) as resp: if resp.status == 200: return True except (urllib.error.URLError, ConnectionError, OSError) as e: - # Surface the kind of failure so a stuck boot doesn't read - # as "silently waiting"; the loop continues until timeout. - sys.stderr.write(f"[wire_parity] waiting for server: {type(e).__name__}\n") + sys.stderr.write(f"[wire_parity:{label}] waiting: {type(e).__name__}\n") time.sleep(POLL_INTERVAL_S) return False -def run_driver(name: str, base_url: str) -> int: - sys.stdout.write(f"\n=== {name} ===\n") +def run_driver(name: str, base_url: str, label: str) -> int: + sys.stdout.write(f"\n=== {label} :: {name} ===\n") sys.stdout.flush() return subprocess.run( ["cargo", "run", "--quiet", "--bin", name, "--", base_url], @@ -60,36 +66,47 @@ def run_driver(name: str, base_url: str) -> int: ).returncode -def main() -> int: - port = pick_free_port() - base_url = f"http://127.0.0.1:{port}/api/mizan" - - server = subprocess.Popen( - ["uv", "run", "uvicorn", "fastapi_app:make_app", - "--factory", "--port", str(port), "--log-level", "warning"], +def boot_fastapi(port: int) -> subprocess.Popen: + return subprocess.Popen( + [ + "uv", "run", "uvicorn", "fastapi_app:make_app", + "--factory", "--port", str(port), "--log-level", "warning", + ], cwd=AFI_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) + +def boot_rust(port: int) -> subprocess.Popen: + return subprocess.Popen( + [ + "cargo", "run", "--quiet", "--release", + "--manifest-path", str(RUST_APP_DIR / "Cargo.toml"), + "--bin", "server", + ], + env={**os.environ, "PORT": str(port)}, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def run_against(label: str, server: subprocess.Popen, port: int) -> int: + failures = 0 try: - if not wait_for_server(port, BOOT_TIMEOUT_S): - sys.stderr.write( - f"[wire_parity] server failed to start within {BOOT_TIMEOUT_S}s\n", - ) - stderr_tail = server.stderr.read(4096) if server.stderr else b"" - if stderr_tail: - sys.stderr.write(stderr_tail.decode("utf-8", errors="replace")) + if not wait_for_server(port, BOOT_TIMEOUT_S, label): + sys.stderr.write(f"[wire_parity:{label}] server boot timed out after {BOOT_TIMEOUT_S}s\n") + if server.stderr: + tail = server.stderr.read(4096) + if tail: + sys.stderr.write(tail.decode("utf-8", errors="replace")) return 1 - - failures = 0 + base_url = f"http://127.0.0.1:{port}/api/mizan" for driver in ("drive_kernel", "drive_emitted"): - rc = run_driver(driver, base_url) + rc = run_driver(driver, base_url, label) if rc != 0: - sys.stderr.write(f"[wire_parity] {driver} exited {rc}\n") + sys.stderr.write(f"[wire_parity:{label}] {driver} exited {rc}\n") failures += 1 - - return 0 if failures == 0 else 1 finally: server.terminate() try: @@ -97,6 +114,21 @@ def main() -> int: except subprocess.TimeoutExpired: server.kill() server.wait() + return failures + + +def main() -> int: + total_failures = 0 + + fastapi_port = pick_free_port() + sys.stdout.write(f"[wire_parity] booting fastapi on port {fastapi_port}\n") + total_failures += run_against("fastapi", boot_fastapi(fastapi_port), fastapi_port) + + rust_port = pick_free_port() + sys.stdout.write(f"[wire_parity] booting rust on port {rust_port}\n") + total_failures += run_against("rust", boot_rust(rust_port), rust_port) + + return 0 if total_failures == 0 else 1 if __name__ == "__main__":