Mizan-Rust backend adapter: server-side substrate + three-way parity

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<T> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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:
"""

View File

@@ -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)

591
backends/mizan-rust-axum/Cargo.lock generated Normal file
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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<MizanError> 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
}
}

View File

@@ -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<String>,
#[serde(rename = "fn")]
pub function_name: Option<String>,
#[serde(default)]
pub args: Map<String, Value>,
}
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<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge: Option<Vec<Value>>,
}
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<CallBody>) -> Result<Response, ApiError> {
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<Value> = 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<Vec<Value>> = 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<String>,
Query(params): Query<BTreeMap<String, String>>,
) -> Result<Response, ApiError> {
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, &params);
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<String, String>,
) -> Map<String, Value> {
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::<i64>().ok().map(Value::from),
mizan_core::Primitive::Number => raw.parse::<f64>().ok().and_then(|v| {
serde_json::Number::from_f64(v).map(Value::Number)
}),
mizan_core::Primitive::Boolean => raw.parse::<bool>().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)
}

View File

@@ -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))
}

View File

@@ -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))

View File

@@ -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"

View File

@@ -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<String>,
}
impl ContextArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
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::<LitStr>(attr_tokens.clone()) {
return Ok(Self {
explicit_name: Some(lit.value()),
});
}
let parser = Punctuated::<Meta, Token![,]>::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(\"<name>\")]` or `#[mizan::context(name = \"<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,
};
}
}

View File

@@ -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 `<camelName>Input` /
/// `<camelName>Output`) and at sub-type discovery inside `Vec<T>` 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<TokenStream> = 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<TokenStream> = 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),*
])
}
}

View File

@@ -0,0 +1,494 @@
//! `#[mizan(...)]` — on async fns. Generates:
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
//! * `MizanType` impl on the Input struct
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_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<Path>,
pub affects: Vec<Path>,
pub merge: Vec<Path>,
pub websocket: bool,
pub private: bool,
}
impl FunctionArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self::default());
}
let parser = Punctuated::<Meta, Token![,]>::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<Path> {
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<Vec<Path>> {
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<T> 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 `<T as MizanType>::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<dyn ::std::future::Future<
Output = ::std::result::Result<
::mizan_core::__priv::serde_json::Value,
::mizan_core::MizanError,
>
> + ::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<Vec<InputArg>> {
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)
}

View File

@@ -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()
}

View File

@@ -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<T>` — caller emits an alias type entry.
pub is_vec: bool,
/// When `is_vec`, this is the element type `T`.
pub vec_inner: Option<Type>,
}
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 `<T as MizanType>::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<TokenStream> {
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<T>`, return `T`. Otherwise None.
pub fn unwrap_option(ty: &Type) -> Option<Type> {
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<T>`, return `T`. Otherwise None.
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
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<Type> {
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
}

173
cores/mizan-rust/Cargo.lock generated Normal file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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<NamedType> {
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()));
}
}
}
}
}

View File

@@ -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 "<Name>" { ... }` section.
#[derive(Debug, Clone)]
pub enum NamedType {
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
Struct(Vec<StructField>),
/// `type "X" { alias { <type-child> } }` — 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<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<&'static str>),
Union(Vec<TypeShape>),
}
#[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<DefaultValue>,
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",
}
}
}

397
cores/mizan-rust/src/kdl.rs Normal file
View File

@@ -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<String>,
}
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<String> = 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<String> = 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<String> = 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()
}

View File

@@ -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;
}

View File

@@ -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()
}

View File

@@ -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<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
}
}
/// 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<String, Value>,
},
/// 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<serde_json::Map<String, Value>>,
}
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<String, Value>,
) -> Vec<InvalidationTarget> {
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<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
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<String> {
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<String, Value>,
) -> serde_json::Map<String, Value> {
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()
}

View File

@@ -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<Box<dyn Future<Output = Result<Value, MizanError>> + 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,
}

View File

@@ -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<OrderOutput> {
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<ProfileOutput> {
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(),
);
}
}

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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<T>(
@@ -89,14 +89,14 @@ export function useUserContext(): ContextState<UserContextData> {
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<Parameters<typeof callUpdateProfile>[0], Awaited<ReturnType<typeof callUpdateProfile>>>(callUpdateProfile)
}
@@ -105,10 +105,6 @@ export function useEcho() {
return useMutation<Parameters<typeof callEcho>[0], Awaited<ReturnType<typeof callEcho>>>(callEcho)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
export function useFindUser() {
return useMutation<Parameters<typeof callFindUser>[0], Awaited<ReturnType<typeof callFindUser>>>(callFindUser)
}
@@ -117,6 +113,10 @@ export function useRenameUser() {
return useMutation<Parameters<typeof callRenameUser>[0], Awaited<ReturnType<typeof callRenameUser>>>(callRenameUser)
}
export function useWhoami() {
return useMutation<void, Awaited<ReturnType<typeof callWhoami>>>(() => callWhoami() as any)
}
// ── MizanContext root provider ──
export interface MizanContextProps {

View File

@@ -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)]

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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<ContextState<UserContextData>>(
@@ -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'

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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<ContextState<UserContextData>>({ 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<userProfileOutput | null>,
userOrders: computed(() => state.value.data?.user_orders ?? null) as ComputedRef<userOrdersOutput | null>,
userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef<userProfileOutput | null>,
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<Error | null>(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<Error | null>(null)
@@ -92,5 +80,17 @@ export function useRenameUser() {
return { mutate, isPending, error }
}
export function useWhoami() {
const isPending = ref(false)
const error = ref<Error | null>(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'

View File

@@ -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 = ["*"]

679
tests/afi/rust_app/Cargo.lock generated Normal file
View File

@@ -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"

View File

@@ -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"

View File

@@ -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());
}

View File

@@ -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();
}

View File

@@ -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<OrderOutput> {
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<ProfileOutput> {
None
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(
_req: &RequestHandle<'_>,
user_id: i64,
name: String,
) -> ProfileOutput {
ProfileOutput { user_id, name }
}

View File

@@ -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."
)

View File

@@ -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,
)
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",
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,
)
stderr_tail = server.stderr.read(4096) if server.stderr else b""
if stderr_tail:
sys.stderr.write(stderr_tail.decode("utf-8", errors="replace"))
return 1
def run_against(label: str, server: subprocess.Popen, port: int) -> int:
failures = 0
try:
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
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__":