diff --git a/backends/mizan-fastapi/src/mizan_fastapi/schema.py b/backends/mizan-fastapi/src/mizan_fastapi/schema.py index dc4c041..34b480d 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/schema.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/schema.py @@ -17,9 +17,10 @@ from typing import Any from fastapi import FastAPI from fastapi.openapi.utils import get_openapi -from pydantic import BaseModel, create_model +from pydantic import BaseModel, RootModel, create_model from mizan_core.registry import get_all_functions, get_context_groups, get_function +from mizan_core.type_utils import extract_list_element, extract_optional __all__ = ["build_schema", "snake_to_camel"] @@ -62,12 +63,20 @@ def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]: input_cls = getattr(fn_class, "Input", None) has_input = _has_input(input_cls) + output_cls = getattr(fn_class, "Output", None) + _, output_nullable = extract_optional(output_cls) if output_cls is not None else (None, False) + entry: dict[str, Any] = { "name": name, "camelName": camel, "hasInput": has_input, "inputType": f"{camel}Input" if has_input else None, "outputType": f"{camel}Output", + # Nullability of the response model — Pydantic `T | None` returns. Carried + # on the function entry rather than the schema class because OpenAPI emits + # `anyOf: [{$ref}, {type:null}]` at the response level, which strict + # deserializers (Rust serde) won't decode as Option without this hint. + "outputNullable": output_nullable, "transport": "websocket" if meta.get("websocket") else "http", "isContext": meta.get("context", False), # Form metadata — always emitted so the schema shape matches Django's, @@ -79,6 +88,8 @@ def _function_metadata(name: str, fn_class: Any) -> dict[str, Any]: if meta.get("affects"): entry["affects"] = meta["affects"] + if meta.get("merge"): + entry["merge"] = meta["merge"] return entry @@ -154,13 +165,28 @@ def build_schema() -> dict[str, Any]: input_type_name = f"{camel}Input" if has_input else None output_type_name = f"{camel}Output" + # Strip Optional so the rename gets a concrete base — nullability is + # carried on the response declaration, not the schema class itself. + output_inner, output_nullable = extract_optional(output_cls) + if has_input: schema_classes[input_type_name] = create_model( input_type_name, __base__=input_cls, ) - schema_classes[output_type_name] = create_model( - output_type_name, __base__=output_cls, - ) + if extract_list_element(output_inner) is not None: + # list[T] — RootModel makes the rename emit `type: array` rather + # than wrapping the list in a property. + schema_classes[output_type_name] = type( + output_type_name, (RootModel[output_inner],), {}, + ) + else: + schema_classes[output_type_name] = create_model( + output_type_name, __base__=output_inner, + ) + + response_model = schema_classes[output_type_name] + if output_nullable: + response_model = response_model | None # Stub endpoint — only exists so FastAPI walks Pydantic types into # components.schemas. Never invoked. Annotations are set explicitly @@ -177,7 +203,7 @@ def build_schema() -> dict[str, Any]: schema_app.post( f"/mizan/{name}", - response_model=schema_classes[output_type_name], + response_model=response_model, operation_id=camel, summary=fn_class.__doc__ or f"Call {name}", )(stub) diff --git a/cores/mizan-python/src/mizan_core/type_utils.py b/cores/mizan-python/src/mizan_core/type_utils.py new file mode 100644 index 0000000..4b91cbb --- /dev/null +++ b/cores/mizan-python/src/mizan_core/type_utils.py @@ -0,0 +1,104 @@ +""" +Type-introspection helpers shared across backend adapters. + +Both mizan-django and mizan-fastapi need to walk @client-decorated function +annotations the same way during schema export. Drift here breaks AFI parity, +so the helpers live in core. +""" + +from __future__ import annotations + +import types +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel + + +__all__ = [ + "extract_optional", + "extract_list_element", + "is_structured_output", + "types_match_for_merge", +] + + +def extract_optional(annotation: Any) -> tuple[Any, bool]: + """Unwrap `Optional[T]` / `T | None`. + + Returns `(T, True)` for a union containing exactly one non-None member + and `None` itself. For anything else, returns `(annotation, False)`. + + Multi-arm unions like `A | B | None` are returned as-is — protocol-level + discriminated unions aren't supported yet, and silently picking one arm + would hide that. + """ + origin = get_origin(annotation) + if origin is Union or isinstance(annotation, types.UnionType): + non_none = [a for a in get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + return non_none[0], True + return annotation, False + + +def extract_list_element(annotation: Any) -> Any | None: + """If `annotation` is `list[T]` (or sibling container of one), return `T`. + + Recognizes `list`, `tuple`, `set`, `frozenset`. For `tuple[T, ...]` the + variadic shape is treated as a homogeneous container; heterogeneous + tuples are not unwrapped. + """ + origin = get_origin(annotation) + if origin not in (list, tuple, set, frozenset): + return None + args = get_args(annotation) + if len(args) == 1: + return args[0] + if origin is tuple and len(args) == 2 and args[1] is Ellipsis: + return args[0] + return None + + +def is_structured_output(annotation: Any) -> bool: + """Recognize return types that don't need a `{result: ...}` primitive wrap. + + Matches `BaseModel`, `Optional[BaseModel]` / `BaseModel | None`, and + container-of-BaseModel (`list[T]`, `tuple[T, ...]`, etc.). Anything else + (primitives, dicts, raw `Any`) is treated as primitive and gets wrapped + so it can ride through Pydantic's typed serialization. + """ + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + return True + origin = get_origin(annotation) + if origin is Union or isinstance(annotation, types.UnionType): + return any( + arg is not type(None) and is_structured_output(arg) + for arg in get_args(annotation) + ) + if origin in (list, tuple, set, frozenset): + return any(is_structured_output(arg) for arg in get_args(annotation)) + return False + + +def types_match_for_merge(slot_type: Any, value_type: Any) -> bool: + """True if a `value_type` mutation return can splice into a `slot_type` context slot. + + Used by backend dispatch to resolve `@client(merge=ctx)` to a concrete + function-name slot inside the context bundle. Three shapes match: + + - direct: slot is `T`, value is `T` → replace + - upsert: slot is `list[T]`, value is `T` → upsert by id + - list replace: slot is `list[T]`, value is `list[T]` + + `Optional[T]` is unwrapped on both sides before comparison. + """ + slot_inner, _ = extract_optional(slot_type) + value_inner, _ = extract_optional(value_type) + if slot_inner is value_inner: + return True + slot_elem = extract_list_element(slot_inner) + if slot_elem is not None and slot_elem is value_inner: + return True + value_elem = extract_list_element(value_inner) + if slot_elem is not None and value_elem is not None and slot_elem is value_elem: + return True + return False diff --git a/frontends/mizan-rust/.gitignore b/frontends/mizan-rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/frontends/mizan-rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/frontends/mizan-rust/Cargo.lock b/frontends/mizan-rust/Cargo.lock new file mode 100644 index 0000000..0c0717b --- /dev/null +++ b/frontends/mizan-rust/Cargo.lock @@ -0,0 +1,1697 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[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 = "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", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mizan-rust" +version = "0.1.0" +dependencies = [ + "pyo3", + "pythonize", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "pythonize" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcf491425978bd889015d5430f6473d91bdfa2097262f1e731aadcf6c2113e" +dependencies = [ + "pyo3", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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_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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[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 0.61.2", +] + +[[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 = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/frontends/mizan-rust/Cargo.toml b/frontends/mizan-rust/Cargo.toml new file mode 100644 index 0000000..de9a11d --- /dev/null +++ b/frontends/mizan-rust/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mizan-rust" +version = "0.1.0" +edition = "2021" +description = "Mizan client kernel — Rust port of @mizan/base. Context registry, fetch/call, merge, invalidation, error envelope parsing. Same wire as the TS / Vue / Svelte clients." +license = "MIT" + +[features] +default = [] +pyo3 = ["dep:pyo3", "dep:pythonize"] + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "io-std"] } +tokio-util = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_urlencoded = "0.7" + +pyo3 = { version = "0.22", optional = true, features = ["extension-module", "abi3-py311"] } +pythonize = { version = "0.22", optional = true } + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/frontends/mizan-rust/src/client.rs b/frontends/mizan-rust/src/client.rs new file mode 100644 index 0000000..1436c12 --- /dev/null +++ b/frontends/mizan-rust/src/client.rs @@ -0,0 +1,195 @@ +//! `MizanClient` — the kernel entry point. +//! +//! Mirrors the `configure(opts)` + module-level state in +//! `frontends/mizan-base/src/index.ts`, but as an owned struct because +//! Rust lacks module-level mutable state. Consumers hold an +//! `Arc` and pass it everywhere the TS code would have +//! used the module-level `config`. +//! +//! Public surface: +//! - `MizanClient::new(config)` — build with reqwest cookie jar. +//! - `client.fetch_context(name, params)` — async, returns parsed JSON bundle. +//! - `client.call(fn_name, args)` — async, applies merge + invalidation +//! from the response then returns `result`. +//! - `client.register_context(name, params, fetch_fn)` — register an +//! instance; returns a `ContextHandle`. +//! - `client.invalidate(name)` / `client.invalidate_scoped(name, params)` +//! — schedule invalidation via the kernel queue. +//! - `client.merge(context, params, slot, value)` — splice a value into +//! a context bundle slot. + +use std::sync::Arc; +use std::time::Duration; + +use reqwest::cookie::CookieStore; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT}; +use reqwest::Url; +use serde_json::Value; +use tokio::sync::OnceCell; + +use crate::context::{ContextHandle, ContextRegistry, FetchFn}; +use crate::error::MizanError; +use crate::invalidation::InvalidationQueue; +use crate::transport; + + +pub struct MizanConfig { + pub base_url: String, + pub session: bool, + pub csrf_cookie_name: String, + pub csrf_header_name: String, + pub extra_headers: Vec<(String, String)>, +} + + +impl Default for MizanConfig { + fn default() -> Self { + Self { + base_url: "/api/mizan".to_string(), + session: true, + csrf_cookie_name: "csrftoken".to_string(), + csrf_header_name: "X-CSRFToken".to_string(), + extra_headers: Vec::new(), + } + } +} + + +pub struct MizanClient { + config: Arc, + http: reqwest::Client, + cookie_jar: Arc, + registry: Arc, + queue: Arc, + session_ready: OnceCell<()>, +} + + +impl MizanClient { + pub fn new(config: MizanConfig) -> Arc { + let cookie_jar = Arc::new(reqwest::cookie::Jar::default()); + let http = reqwest::Client::builder() + .cookie_provider(Arc::clone(&cookie_jar)) + .build() + .expect("reqwest client construction"); + let registry = Arc::new(ContextRegistry::new()); + let queue = InvalidationQueue::new(Arc::clone(®istry)); + Arc::new(Self { + config: Arc::new(config), + http, + cookie_jar, + registry, + queue, + session_ready: OnceCell::new(), + }) + } + + pub fn config(&self) -> &MizanConfig { + &self.config + } + + pub fn http(&self) -> &reqwest::Client { + &self.http + } + + pub fn context_registry(&self) -> &Arc { + &self.registry + } + + pub fn invalidation_queue(&self) -> &Arc { + &self.queue + } + + /// Hit `/session/` once on first call to bootstrap the CSRF cookie. + /// No-op when `config.session == false`. Three attempts with 100ms + /// × attempt backoff. + pub async fn ensure_session_ready(&self) -> Result<(), MizanError> { + if !self.config.session { + return Ok(()); + } + self.session_ready + .get_or_try_init(|| async { + if self.read_csrf_cookie().is_some() { + return Ok(()); + } + let url = Url::parse(&format!("{}/session/", self.config.base_url.trim_end_matches('/'))) + .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?; + for attempt in 0..3 { + let res = self.http.get(url.clone()).send().await; + if res.is_ok() && self.read_csrf_cookie().is_some() { + return Ok(()); + } + if attempt < 2 { + tokio::time::sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await; + } + } + // Mirror TS: failing to bootstrap is non-fatal — subsequent + // calls proceed without CSRF and may still succeed (e.g., + // FastAPI configs that don't require it). + Ok(()) + }) + .await + .copied() + } + + pub(crate) async fn resolve_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in &self.config.extra_headers { + if let (Ok(n), Ok(v)) = (HeaderName::try_from(name.as_str()), HeaderValue::try_from(value.as_str())) { + headers.insert(n, v); + } + } + if let Some(token) = self.read_csrf_cookie() { + if let (Ok(n), Ok(v)) = ( + HeaderName::try_from(self.config.csrf_header_name.as_str()), + HeaderValue::try_from(token.as_str()), + ) { + headers.insert(n, v); + } + } + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + headers + } + + fn read_csrf_cookie(&self) -> Option { + let url = Url::parse(&self.config.base_url).ok()?; + let header = self.cookie_jar.cookies(&url)?; + let raw = header.to_str().ok()?; + let needle = format!("{}=", self.config.csrf_cookie_name); + raw.split(';') + .map(|p| p.trim()) + .find_map(|p| p.strip_prefix(&needle)) + .map(|v| v.trim_matches('"').to_string()) + } + + // ── High-level API ───────────────────────────────────────────────── + + pub async fn fetch_context(&self, context: &str, params: &Value) -> Result { + transport::mizan_fetch(self, context, params).await + } + + pub async fn call(&self, fn_name: &str, args: Value) -> Result { + transport::mizan_call(self, fn_name, args).await + } + + pub async fn register_context( + self: &Arc, + name: impl Into, + params: Value, + fetch_fn: FetchFn, + ) -> ContextHandle { + self.registry.register(name, params, fetch_fn, None).await + } + + pub async fn invalidate(self: &Arc, name: impl Into) { + self.queue.invalidate(name).await; + } + + pub async fn invalidate_scoped(self: &Arc, name: impl Into, params: Value) { + self.queue.invalidate_scoped(name, params).await; + } + + pub async fn merge(&self, context: &str, params: Option<&Value>, slot: &str, value: &Value) { + self.registry.merge(context, params, slot, value).await; + } +} diff --git a/frontends/mizan-rust/src/context.rs b/frontends/mizan-rust/src/context.rs new file mode 100644 index 0000000..82ca971 --- /dev/null +++ b/frontends/mizan-rust/src/context.rs @@ -0,0 +1,365 @@ +//! Context registry. +//! +//! Mirrors the `contexts: Map>` +//! shape in `frontends/mizan-base/src/index.ts`. Each entry holds the +//! latest `ContextState`, a `tokio::sync::watch::Sender` for notifying +//! subscribers, and a fetch function the registry invokes on demand. +//! +//! Subscribers receive a `ContextHandle` whose `rx: watch::Receiver` +//! they read from in their own loop. Watch channels overwrite the +//! previous value if the receiver hasn't consumed it yet — the render +//! loop sees only the latest state on each tick, never an intermediate +//! one. The TS kernel achieves the same effect via React's external +//! store re-render coalescing. + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde_json::Value; +use tokio::sync::{Mutex, RwLock, mpsc, watch}; +use tokio_util::sync::CancellationToken; + +use crate::error::MizanError; + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextStatus { + Idle, + Loading, + Success, + Error, +} + + +#[derive(Debug, Clone)] +pub struct ContextState { + pub data: Option, + pub status: ContextStatus, + pub error: Option>, +} + + +pub type ContextStateRaw = ContextState; + + +impl ContextStateRaw { + pub fn idle() -> Self { + Self { data: None, status: ContextStatus::Idle, error: None } + } +} + + +pub type FetchFn = Arc< + dyn Fn() -> Pin> + Send + 'static>> + + Send + + Sync, +>; + + +struct ContextEntry { + #[allow(dead_code)] + params: Value, + tx: watch::Sender, + fetch_fn: FetchFn, + refetch_tx: mpsc::UnboundedSender<()>, + /// Cancel signal for the entry's spawned refetch loop. Set when the + /// last handle on the entry unregisters. + cancel: CancellationToken, +} + + +pub struct ContextRegistry { + /// Outer key: context name. Inner key: `stable_key(params)`. + entries: RwLock>>>>, +} + + +impl Default for ContextRegistry { + fn default() -> Self { + Self::new() + } +} + + +impl ContextRegistry { + pub fn new() -> Self { + Self { entries: RwLock::new(HashMap::new()) } + } + + /// Register an instance of `(context_name, params)`. Idempotent — + /// re-registering the same key returns a handle on the existing + /// entry (the fetch_fn closure is replaced so the latest binding + /// wins). + pub async fn register( + self: &Arc, + name: impl Into, + params: Value, + fetch_fn: FetchFn, + initial_data: Option, + ) -> ContextHandle { + let name = name.into(); + let key = stable_key(¶ms); + + let mut outer = self.entries.write().await; + let inner = outer.entry(name.clone()).or_default(); + + if let Some(existing) = inner.get(&key).cloned() { + // Update the fetch closure so the latest registration's + // closure wins (matches the TS Strict-Mode behavior). + { + let mut entry = existing.lock().await; + entry.fetch_fn = fetch_fn; + } + let entry = existing.lock().await; + return ContextHandle { + rx: entry.tx.subscribe(), + refetch_tx: entry.refetch_tx.clone(), + cancel: entry.cancel.clone(), + registry: Arc::clone(self), + name, + key, + }; + } + + let initial = match initial_data { + Some(data) => ContextState { data: Some(data), status: ContextStatus::Success, error: None }, + None => ContextStateRaw::idle(), + }; + let (tx, _rx) = watch::channel(initial); + let (refetch_tx, mut refetch_rx) = mpsc::unbounded_channel::<()>(); + let cancel = CancellationToken::new(); + + let entry = Arc::new(Mutex::new(ContextEntry { + params: params.clone(), + tx: tx.clone(), + fetch_fn: fetch_fn.clone(), + refetch_tx: refetch_tx.clone(), + cancel: cancel.clone(), + })); + inner.insert(key.clone(), Arc::clone(&entry)); + drop(outer); + + // Spawn the entry's refetch loop. The loop owns its own fetch + // closure handle resolution via the entry mutex — each tick + // reads the latest closure, so updates via re-register apply. + let entry_for_task = Arc::clone(&entry); + let cancel_for_task = cancel.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel_for_task.cancelled() => break, + msg = refetch_rx.recv() => { + if msg.is_none() { break; } + let (fetch_fn, tx) = { + let entry = entry_for_task.lock().await; + (entry.fetch_fn.clone(), entry.tx.clone()) + }; + // Loading state + let cur = tx.borrow().clone(); + let loading = ContextState { data: cur.data, status: ContextStatus::Loading, error: None }; + let _ = tx.send(loading); + // Drive the fetch + match fetch_fn().await { + Ok(data) => { + let _ = tx.send(ContextState { data: Some(data), status: ContextStatus::Success, error: None }); + } + Err(err) => { + let cur = tx.borrow().clone(); + let _ = tx.send(ContextState { + data: cur.data, + status: ContextStatus::Error, + error: Some(Arc::new(err)), + }); + } + } + } + } + } + }); + + ContextHandle { + rx: tx.subscribe(), + refetch_tx, + cancel, + registry: Arc::clone(self), + name, + key, + } + } + + /// Merge a value into a context entry's bundle slot. Mirrors the + /// TS kernel `merge(context, params, slot, value)` call. + pub async fn merge( + &self, + name: &str, + params: Option<&Value>, + slot: &str, + value: &Value, + ) { + let key = match params { + Some(p) => stable_key(p), + None => stable_key(&Value::Object(Default::default())), + }; + let entry_handle = { + let outer = self.entries.read().await; + outer.get(name).and_then(|inner| inner.get(&key)).cloned() + }; + let Some(entry_arc) = entry_handle else { return }; + let entry = entry_arc.lock().await; + let cur = entry.tx.borrow().clone(); + let Some(bundle) = cur.data.as_ref() else { return }; + let Some(merged) = crate::merge::merge_into_bundle(bundle, slot, value) else { return }; + let _ = entry.tx.send(ContextState { + data: Some(merged), + status: ContextStatus::Success, + error: None, + }); + } + + /// Trigger refetch on every entry of `name`. + pub async fn invalidate_broad(&self, name: &str) { + let entries = { + let outer = self.entries.read().await; + outer.get(name).map(|inner| inner.values().cloned().collect::>()) + }; + let Some(entries) = entries else { return }; + for entry in entries { + let tx = { + let e = entry.lock().await; + e.refetch_tx.clone() + }; + let _ = tx.send(()); + } + } + + /// Trigger refetch on the single entry matching `(name, params)`. + pub async fn invalidate_scoped(&self, name: &str, params: &Value) { + let key = stable_key(params); + let entry_arc = { + let outer = self.entries.read().await; + outer.get(name).and_then(|inner| inner.get(&key)).cloned() + }; + let Some(entry_arc) = entry_arc else { return }; + let tx = { + let entry = entry_arc.lock().await; + entry.refetch_tx.clone() + }; + let _ = tx.send(()); + } + + async fn unregister(&self, name: &str, key: &str) { + let mut outer = self.entries.write().await; + if let Some(inner) = outer.get_mut(name) { + if let Some(entry) = inner.remove(key) { + let entry = entry.lock().await; + entry.cancel.cancel(); + } + if inner.is_empty() { + outer.remove(name); + } + } + } +} + + +pub struct ContextHandle { + pub rx: watch::Receiver, + refetch_tx: mpsc::UnboundedSender<()>, + cancel: CancellationToken, + registry: Arc, + name: String, + key: String, +} + + +impl ContextHandle { + /// Drive a refetch. Returns immediately; the new state lands on + /// `rx` once the kernel's refetch task finishes the fetch. + pub fn refetch(&self) { + let _ = self.refetch_tx.send(()); + } + + pub fn state(&self) -> ContextStateRaw { + self.rx.borrow().clone() + } + + pub fn cancel_token(&self) -> CancellationToken { + self.cancel.clone() + } + + pub async fn unregister(self) { + self.registry.unregister(&self.name, &self.key).await; + } +} + + +/// Byte-identical to TS `JSON.stringify(params, Object.keys(params).sort())`. +/// +/// Uses `BTreeMap` for deterministic key ordering and serializes via +/// `serde_json::to_string` (compact, no whitespace) — matches the TS +/// default. Non-object / non-string params (numbers, booleans) pass +/// through serde_json's standard JSON representation. +pub fn stable_key(params: &Value) -> String { + match params { + Value::Object(map) => { + let sorted: BTreeMap<&String, &Value> = map.iter().collect(); + serde_json::to_string(&sorted).unwrap_or_default() + } + other => serde_json::to_string(other).unwrap_or_default(), + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn stable_key_sorts_object_keys() { + let a = stable_key(&json!({"b": 1, "a": 2})); + let b = stable_key(&json!({"a": 2, "b": 1})); + assert_eq!(a, b); + assert_eq!(a, r#"{"a":2,"b":1}"#); + } + + #[test] + fn stable_key_handles_empty_object() { + assert_eq!(stable_key(&json!({})), "{}"); + } + + #[tokio::test] + async fn register_and_refetch() { + let registry = Arc::new(ContextRegistry::new()); + let counter = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let counter_clone = Arc::clone(&counter); + let fetch_fn: FetchFn = Arc::new(move || { + let counter = Arc::clone(&counter_clone); + Box::pin(async move { + let n = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + Ok(json!({ "count": n })) + }) + }); + + let mut handle = registry.register("test", json!({}), fetch_fn, None).await; + handle.refetch(); + // Poll until success — watch::Receiver::changed() returns once + // per "newest value seen" advance, so back-to-back sends from the + // refetch task can coalesce into a single notification. The loop + // ignores intermediate Loading states and waits for Success. + loop { + tokio::time::timeout(std::time::Duration::from_secs(2), handle.rx.changed()) + .await + .expect("changed timed out") + .unwrap(); + if handle.state().status == ContextStatus::Success { + break; + } + } + let state = handle.state(); + assert_eq!(state.data.unwrap()["count"], 1); + } +} diff --git a/frontends/mizan-rust/src/error.rs b/frontends/mizan-rust/src/error.rs new file mode 100644 index 0000000..2df9138 --- /dev/null +++ b/frontends/mizan-rust/src/error.rs @@ -0,0 +1,121 @@ +//! Wire error envelope. Mirrors `MizanError` in `frontends/mizan-base/src/index.ts`. +//! +//! Two envelope shapes are tolerated: +//! +//! - FastAPI: `{"error": {"code": "...", "message": "...", "details": ...}}` +//! - Django: `{"error": true, "code": "...", "message": "...", "details": ...}` +//! +//! When neither shape parses, `code` falls back to `HTTP_` and the +//! raw response body is the message. + +use serde::Deserialize; +use serde_json::Value; + + +#[derive(Debug, Clone)] +pub struct MizanError { + pub status: u16, + pub code: String, + pub message: String, + pub details: Option, + pub raw_body: String, +} + + +impl MizanError { + pub fn from_response(status: u16, body: String) -> Self { + let parsed = serde_json::from_str::(&body).ok(); + let (code, message, details) = match parsed { + Some(Envelope::Fastapi { error }) => ( + error.code.unwrap_or_else(|| format!("HTTP_{status}")), + error.message.unwrap_or_else(|| format!("Mizan call failed ({status})")), + error.details, + ), + Some(Envelope::Django { code, message, details, .. }) => ( + code.unwrap_or_else(|| format!("HTTP_{status}")), + message.unwrap_or_else(|| format!("Mizan call failed ({status})")), + details, + ), + None => ( + format!("HTTP_{status}"), + format!("Mizan call failed ({status})"), + None, + ), + }; + Self { status, code, message, details, raw_body: body } + } + + pub fn transport(message: impl Into) -> Self { + Self { + status: 0, + code: "TRANSPORT".to_string(), + message: message.into(), + details: None, + raw_body: String::new(), + } + } +} + + +impl std::fmt::Display for MizanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Mizan {} ({}): {}", self.status, self.code, self.message) + } +} + + +impl std::error::Error for MizanError {} + + +#[derive(Deserialize)] +#[serde(untagged)] +enum Envelope { + Fastapi { error: NestedError }, + Django { + // Django form is `{"error": true, "code": ..., "message": ..., "details": ...}`. + // `error` is a bool sentinel; the actual fields are siblings. + #[allow(dead_code)] + error: bool, + code: Option, + message: Option, + details: Option, + }, +} + + +#[derive(Deserialize)] +struct NestedError { + code: Option, + message: Option, + details: Option, +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_fastapi_envelope() { + let body = r#"{"error":{"code":"BAD_REQUEST","message":"oops","details":{"k":1}}}"#; + let e = MizanError::from_response(400, body.to_string()); + assert_eq!(e.code, "BAD_REQUEST"); + assert_eq!(e.message, "oops"); + assert_eq!(e.details, Some(serde_json::json!({"k": 1}))); + } + + #[test] + fn parses_django_envelope() { + let body = r#"{"error":true,"code":"NOT_FOUND","message":"missing","details":null}"#; + let e = MizanError::from_response(404, body.to_string()); + assert_eq!(e.code, "NOT_FOUND"); + assert_eq!(e.message, "missing"); + } + + #[test] + fn falls_back_on_unparseable_body() { + let e = MizanError::from_response(500, "Internal Server Error".to_string()); + assert_eq!(e.code, "HTTP_500"); + assert!(e.message.contains("500")); + } +} diff --git a/frontends/mizan-rust/src/invalidation.rs b/frontends/mizan-rust/src/invalidation.rs new file mode 100644 index 0000000..18c2736 --- /dev/null +++ b/frontends/mizan-rust/src/invalidation.rs @@ -0,0 +1,148 @@ +//! Invalidation queue. +//! +//! Mirrors the TS kernel's `pending` / `pendingScoped` / `flush()` pair +//! at `frontends/mizan-base/src/index.ts`. Mutations accumulate +//! invalidation targets; the queue batches them and triggers refetches +//! on the matching context entries. +//! +//! The TS kernel uses `queueMicrotask(flush)` to batch within a single +//! event-loop tick. The Rust equivalent is a `tokio::task::yield_now()` +//! debounce: when `invalidate()` is called, push to the queue, and if +//! no flush is scheduled spawn a task that yields once then flushes. +//! That gives the same "batch within a single async tick" semantics. + +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde_json::Value; +use tokio::sync::Mutex; + +use crate::context::ContextRegistry; + + +#[derive(Debug, Clone)] +pub struct ScopedTarget { + pub context: String, + pub params: Value, +} + + +#[derive(Default)] +struct Pending { + broad: HashSet, + scoped: Vec, +} + + +pub struct InvalidationQueue { + pending: Mutex, + scheduled: AtomicBool, + registry: Arc, +} + + +impl InvalidationQueue { + pub fn new(registry: Arc) -> Arc { + Arc::new(Self { + pending: Mutex::new(Pending::default()), + scheduled: AtomicBool::new(false), + registry, + }) + } + + /// Schedule a broad invalidation (every entry of `name` refetches). + pub async fn invalidate(self: &Arc, name: impl Into) { + { + let mut pending = self.pending.lock().await; + pending.broad.insert(name.into()); + } + self.schedule_flush(); + } + + /// Schedule a scoped invalidation (the entry matching `(name, + /// params)` refetches). + pub async fn invalidate_scoped(self: &Arc, name: impl Into, params: Value) { + { + let mut pending = self.pending.lock().await; + pending.scoped.push(ScopedTarget { context: name.into(), params }); + } + self.schedule_flush(); + } + + fn schedule_flush(self: &Arc) { + if self.scheduled.swap(true, Ordering::SeqCst) { + return; + } + let this = Arc::clone(self); + tokio::spawn(async move { + // Yield once to batch invalidations queued in the same + // async tick — equivalent to TS `queueMicrotask`. + tokio::task::yield_now().await; + this.flush().await; + this.scheduled.store(false, Ordering::SeqCst); + }); + } + + async fn flush(&self) { + let snapshot = { + let mut pending = self.pending.lock().await; + let broad = std::mem::take(&mut pending.broad); + let scoped = std::mem::take(&mut pending.scoped); + (broad, scoped) + }; + let (broad, scoped) = snapshot; + + // Broad first — they cover all scoped variants of the same name. + for name in &broad { + self.registry.invalidate_broad(name).await; + } + for target in &scoped { + if broad.contains(&target.context) { + continue; + } + self.registry.invalidate_scoped(&target.context, &target.params).await; + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::{ContextHandle, ContextRegistry, ContextStatus, FetchFn}; + use serde_json::json; + + fn counted_fetch(counter: Arc) -> FetchFn { + Arc::new(move || { + let counter = Arc::clone(&counter); + Box::pin(async move { + let n = counter.fetch_add(1, Ordering::SeqCst) + 1; + Ok(json!({ "count": n })) + }) + }) + } + + async fn wait_for_success(handle: &mut ContextHandle) { + loop { + handle.rx.changed().await.unwrap(); + if handle.state().status == ContextStatus::Success { + return; + } + } + } + + #[tokio::test] + async fn broad_invalidate_triggers_refetch() { + let registry = Arc::new(ContextRegistry::new()); + let queue = InvalidationQueue::new(Arc::clone(®istry)); + let counter = Arc::new(std::sync::atomic::AtomicU32::new(0)); + let mut handle = registry.register("user", json!({}), counted_fetch(Arc::clone(&counter)), None).await; + handle.refetch(); + wait_for_success(&mut handle).await; + assert_eq!(counter.load(Ordering::SeqCst), 1); + queue.invalidate("user").await; + wait_for_success(&mut handle).await; + assert_eq!(counter.load(Ordering::SeqCst), 2); + } +} diff --git a/frontends/mizan-rust/src/lib.rs b/frontends/mizan-rust/src/lib.rs new file mode 100644 index 0000000..da39769 --- /dev/null +++ b/frontends/mizan-rust/src/lib.rs @@ -0,0 +1,28 @@ +//! Mizan client kernel. +//! +//! Rust port of `@mizan/base` (frontends/mizan-base/src/index.ts). Same +//! public surface, same protocol, same wire shape. Consumers — generated +//! per-app crates, the GPU worker, the Python `PyMizanClient` — depend +//! on this kernel and never construct HTTP requests directly. +//! +//! Modules: +//! - [`client`] — `MizanClient`, `MizanConfig`, session init +//! - [`context`] — registry, `ContextState`, `ContextHandle`, `stable_key` +//! - [`error`] — `MizanError`, envelope parsing +//! - [`transport`] — `mizan_fetch`, `mizan_call`, retry, header resolution +//! - [`merge`] — `splice_slot` +//! - [`invalidation`] — `InvalidationQueue`, debounced flush + +pub mod client; +pub mod context; +pub mod error; +pub mod invalidation; +pub mod merge; +pub mod transport; + +#[cfg(feature = "pyo3")] +pub mod pyo3_bridge; + +pub use client::{MizanClient, MizanConfig}; +pub use context::{ContextHandle, ContextState, ContextStateRaw, ContextStatus, stable_key}; +pub use error::MizanError; diff --git a/frontends/mizan-rust/src/merge.rs b/frontends/mizan-rust/src/merge.rs new file mode 100644 index 0000000..867f91a --- /dev/null +++ b/frontends/mizan-rust/src/merge.rs @@ -0,0 +1,107 @@ +//! Mutation-driven merge of a value into a context's bundle slot. +//! +//! Mirrors `spliceSlot` in `frontends/mizan-base/src/index.ts`. The server +//! has already resolved which slot the value lands in (by matching the +//! mutation's return type against each context function's return type), +//! so the kernel does no inference — it writes directly to `bundle[slot]`. +//! +//! Rules: +//! - If the existing slot is an array and the new value is also an array, +//! the array replaces the slot wholesale. +//! - If the existing slot is an array and the new value is an object with +//! an `id` field, upsert by `id` — replace the matching entry in place +//! or append. +//! - Otherwise the slot is replaced with the new value. + +use serde_json::Value; + + +pub fn splice_slot(slot: &Value, value: &Value) -> Value { + if let Value::Array(slot_arr) = slot { + if let Value::Array(_) = value { + return value.clone(); + } + if let Some(id) = value.get("id") { + let mut next = slot_arr.clone(); + let idx = next.iter().position(|item| item.get("id") == Some(id)); + match idx { + Some(i) => next[i] = value.clone(), + None => next.push(value.clone()), + } + return Value::Array(next); + } + } + value.clone() +} + + +/// Apply a merge entry to the bundle of a context entry. Returns the new +/// bundle, or `None` if the slot wasn't present in the bundle (caller +/// should treat that as a no-op so server-driven merges into stale +/// caches don't fabricate slots). +pub fn merge_into_bundle(bundle: &Value, slot_name: &str, value: &Value) -> Option { + let obj = bundle.as_object()?; + if !obj.contains_key(slot_name) { + return None; + } + let mut next = obj.clone(); + let spliced = splice_slot(obj.get(slot_name)?, value); + next.insert(slot_name.to_string(), spliced); + Some(Value::Object(next)) +} + + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn replaces_scalar_slot() { + let slot = json!(1); + let value = json!(2); + assert_eq!(splice_slot(&slot, &value), json!(2)); + } + + #[test] + fn upserts_array_by_id() { + let slot = json!([{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]); + let value = json!({"id": 1, "name": "A"}); + assert_eq!( + splice_slot(&slot, &value), + json!([{"id": 1, "name": "A"}, {"id": 2, "name": "b"}]), + ); + } + + #[test] + fn appends_when_id_not_in_array() { + let slot = json!([{"id": 1, "name": "a"}]); + let value = json!({"id": 9, "name": "z"}); + assert_eq!( + splice_slot(&slot, &value), + json!([{"id": 1, "name": "a"}, {"id": 9, "name": "z"}]), + ); + } + + #[test] + fn array_replaces_array() { + let slot = json!([1, 2, 3]); + let value = json!([7, 8]); + assert_eq!(splice_slot(&slot, &value), json!([7, 8])); + } + + #[test] + fn merge_into_bundle_skips_missing_slot() { + let bundle = json!({"existing": 1}); + let value = json!(42); + assert!(merge_into_bundle(&bundle, "missing", &value).is_none()); + } + + #[test] + fn merge_into_bundle_updates_present_slot() { + let bundle = json!({"user_profile": {"id": 1, "name": "old"}}); + let value = json!({"id": 1, "name": "new"}); + let merged = merge_into_bundle(&bundle, "user_profile", &value).unwrap(); + assert_eq!(merged["user_profile"]["name"], "new"); + } +} diff --git a/frontends/mizan-rust/src/pyo3_bridge.rs b/frontends/mizan-rust/src/pyo3_bridge.rs new file mode 100644 index 0000000..0918682 --- /dev/null +++ b/frontends/mizan-rust/src/pyo3_bridge.rs @@ -0,0 +1,252 @@ +//! PyO3 façade — exposes `MizanClient` to Python as `PyMizanClient`. +//! +//! Same kernel, same wire. The Python wrapper that the codegen emits +//! adds typed methods on top of this client (Pydantic in / Pydantic +//! out); this module's job is the GIL boundary plus the async-to-sync +//! bridge. +//! +//! Architecture: +//! - One tokio multi-thread runtime owned by the `PyMizanClient`. +//! - `call` / `fetch_context` use `py.allow_threads(|| rt.block_on(...))` +//! so the GIL is released across the network round-trip. +//! - `subscribe_context` spawns a tokio task that owns a watch +//! receiver; on each change the task acquires the GIL via +//! `Python::with_gil` and fires the Python callback. The returned +//! `CancellationToken` (wrapped as `PyContextSubscription`) lets +//! Python cancel the watcher. + +use std::sync::Arc; + +use pyo3::prelude::*; +use pyo3::types::{PyDict}; +use pythonize::{depythonize, pythonize}; +use serde_json::Value; +use tokio::runtime::Runtime; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use crate::client::{MizanClient, MizanConfig}; +use crate::context::{ContextStateRaw, ContextStatus}; + + +#[pyclass] +pub struct PyMizanClient { + inner: Arc, + rt: Arc, +} + + +#[pyclass] +pub struct PyContextSubscription { + cancel: CancellationToken, +} + + +#[pymethods] +impl PyContextSubscription { + fn cancel(&self) { + self.cancel.cancel(); + } +} + + +#[pymethods] +impl PyMizanClient { + #[new] + #[pyo3(signature = (base_url, *, session = false, csrf_cookie_name = String::from("csrftoken"), csrf_header_name = String::from("X-CSRFToken")))] + fn new( + base_url: String, + session: bool, + csrf_cookie_name: String, + csrf_header_name: String, + ) -> PyResult { + let rt = Runtime::new() + .map_err(|e| PyErr::new::(format!("tokio runtime: {e}")))?; + let config = MizanConfig { + base_url, + session, + csrf_cookie_name, + csrf_header_name, + extra_headers: Vec::new(), + }; + Ok(Self { + inner: MizanClient::new(config), + rt: Arc::new(rt), + }) + } + + /// Invoke a mutation or plain function. `args` is a Python dict (or + /// any pythonize-compatible object). Returns the unwrapped `result` + /// from the server response as a Python object. + fn call(&self, py: Python<'_>, fn_name: String, args: &Bound<'_, PyDict>) -> PyResult { + let args_value: Value = depythonize(args.as_any()) + .map_err(|e| PyErr::new::(format!("args: {e}")))?; + let inner = Arc::clone(&self.inner); + let result: Value = py.allow_threads(|| { + self.rt.block_on(async move { inner.call(&fn_name, args_value).await }) + }) + .map_err(mizan_err_to_py)?; + pythonize(py, &result) + .map_err(|e| PyErr::new::(format!("encode result: {e}"))) + .map(|bound| bound.unbind()) + } + + /// One-shot context fetch (does not register a subscription). + fn fetch_context(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult { + let params_value: Value = depythonize(params.as_any()) + .map_err(|e| PyErr::new::(format!("params: {e}")))?; + let inner = Arc::clone(&self.inner); + let result: Value = py.allow_threads(|| { + self.rt.block_on(async move { inner.fetch_context(&name, ¶ms_value).await }) + }) + .map_err(mizan_err_to_py)?; + pythonize(py, &result) + .map_err(|e| PyErr::new::(format!("encode result: {e}"))) + .map(|bound| bound.unbind()) + } + + /// Register a subscription. The kernel owns the fetch lifecycle; + /// `callback` is invoked from the watcher task once per state + /// change. Returns a handle whose `.cancel()` ends the subscription. + /// + /// The callback receives a dict with keys: `data`, `status`, + /// `error`. Status is one of `"idle"`, `"loading"`, `"success"`, + /// `"error"`. Error is a dict with `code`, `message`, `status`, + /// `details` (or None). + #[pyo3(signature = (name, params, callback))] + fn subscribe_context( + &self, + py: Python<'_>, + name: String, + params: &Bound<'_, PyDict>, + callback: PyObject, + ) -> PyResult { + let params_value: Value = depythonize(params.as_any()) + .map_err(|e| PyErr::new::(format!("params: {e}")))?; + + // Build a serde-friendly fetch closure that delegates to the + // kernel's `fetch_context` (which itself runs the typed HTTP + // pipeline). The subscription's refetches go through this. + let inner_for_fetch = Arc::clone(&self.inner); + let name_for_fetch = name.clone(); + let params_for_fetch = params_value.clone(); + let fetch_fn = Arc::new(move || { + let inner = Arc::clone(&inner_for_fetch); + let name = name_for_fetch.clone(); + let params = params_for_fetch.clone(); + Box::pin(async move { inner.fetch_context(&name, ¶ms).await }) + as std::pin::Pin + Send + 'static>> + }); + + let inner = Arc::clone(&self.inner); + let handle = py.allow_threads(|| { + self.rt.block_on(async move { + inner.register_context(name.clone(), params_value, fetch_fn).await + }) + }); + let cancel = handle.cancel_token(); + let cancel_for_task = cancel.clone(); + let callback = Arc::new(callback); + let callback_for_task = Arc::clone(&callback); + // Drive an initial refetch before destructuring so the first + // state lands without requiring the caller to invalidate. + handle.refetch(); + let rx: watch::Receiver = handle.rx; + + self.rt.spawn(async move { + let mut rx = rx; + loop { + tokio::select! { + _ = cancel_for_task.cancelled() => break, + res = rx.changed() => { + if res.is_err() { break; } + let snapshot = rx.borrow_and_update().clone(); + Python::with_gil(|py| { + let dict = match state_to_pydict(py, &snapshot) { + Ok(d) => d, + Err(e) => { eprintln!("[pyo3_bridge] encode state: {e}"); return; } + }; + if let Err(e) = callback_for_task.call1(py, (dict,)) { + eprintln!("[pyo3_bridge] callback raised: {e}"); + } + }); + } + } + } + }); + + Ok(PyContextSubscription { cancel }) + } + + /// Schedule a broad invalidation. + fn invalidate(&self, py: Python<'_>, name: String) { + let inner = Arc::clone(&self.inner); + py.allow_threads(|| { + self.rt.block_on(async move { inner.invalidate(name).await }) + }); + } + + /// Schedule a scoped invalidation. + fn invalidate_scoped(&self, py: Python<'_>, name: String, params: &Bound<'_, PyDict>) -> PyResult<()> { + let params_value: Value = depythonize(params.as_any()) + .map_err(|e| PyErr::new::(format!("params: {e}")))?; + let inner = Arc::clone(&self.inner); + py.allow_threads(|| { + self.rt.block_on(async move { inner.invalidate_scoped(name, params_value).await }) + }); + Ok(()) + } +} + + +fn state_to_pydict<'py>(py: Python<'py>, state: &ContextStateRaw) -> PyResult> { + let dict = PyDict::new_bound(py); + let status = match state.status { + ContextStatus::Idle => "idle", + ContextStatus::Loading => "loading", + ContextStatus::Success => "success", + ContextStatus::Error => "error", + }; + dict.set_item("status", status)?; + match &state.data { + Some(v) => { + let obj = pythonize(py, v) + .map_err(|e| PyErr::new::(format!("encode state.data: {e}")))?; + dict.set_item("data", obj)?; + } + None => dict.set_item("data", py.None())?, + } + match &state.error { + Some(err) => { + let err_dict = PyDict::new_bound(py); + err_dict.set_item("status", err.status)?; + err_dict.set_item("code", &err.code)?; + err_dict.set_item("message", &err.message)?; + if let Some(details) = &err.details { + let obj = pythonize(py, details) + .map_err(|e| PyErr::new::(format!("encode error.details: {e}")))?; + err_dict.set_item("details", obj)?; + } else { + err_dict.set_item("details", py.None())?; + } + dict.set_item("error", err_dict)?; + } + None => dict.set_item("error", py.None())?, + } + Ok(dict) +} + + +fn mizan_err_to_py(err: crate::MizanError) -> PyErr { + PyErr::new::(format!("{err}")) +} + + +/// Python extension module entry point. Wheels built via `maturin +/// develop --features pyo3` import the module as `mizan_rust`. +#[pymodule] +fn mizan_rust(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/frontends/mizan-rust/src/transport.rs b/frontends/mizan-rust/src/transport.rs new file mode 100644 index 0000000..f1111a1 --- /dev/null +++ b/frontends/mizan-rust/src/transport.rs @@ -0,0 +1,153 @@ +//! HTTP transport. Mirrors `mizanFetch` and `mizanCall` in +//! `frontends/mizan-base/src/index.ts`. +//! +//! - `mizan_fetch(client, context, params)` → `GET /api/mizan/ctx//?params` +//! - `mizan_call(client, fn_name, args)` → `POST /api/mizan/call/` with +//! `{fn, args}` body. On response, applies any `merge` entries first, +//! then `invalidate` entries, then returns the `result` field. +//! +//! Retries: 3 attempts total, 200ms × attempt linear backoff. Retries +//! on network errors and 5xx; surfaces 4xx immediately (matches TS). +//! +//! CSRF: the reqwest cookie jar stores the CSRF cookie from the +//! `/session/` bootstrap; on every call we read it via +//! `reqwest::cookie::Jar::cookies(&url)` and add it as the configured +//! header. Both names come from `MizanConfig`. + +use std::time::Duration; + +use reqwest::{Method, Url}; +use serde::Deserialize; +use serde_json::Value; + +use crate::client::MizanClient; +use crate::error::MizanError; + + +const MAX_ATTEMPTS: u32 = 3; +const BACKOFF_BASE: Duration = Duration::from_millis(200); + + +/// `GET /api/mizan/ctx//?params`. +pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) -> Result { + let mut url = Url::parse(&format!("{}/ctx/{}/", client.config().base_url.trim_end_matches('/'), context)) + .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?; + if let Value::Object(map) = params { + let mut qp = url.query_pairs_mut(); + for (k, v) in map { + let s = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + qp.append_pair(k, &s); + } + } + + let body = request_with_retry(client, Method::GET, url, None).await?; + serde_json::from_str(&body).map_err(|e| MizanError::transport(format!("decode: {e}"))) +} + + +/// `POST /api/mizan/call/` with `{fn, args}` body. Applies merge + +/// invalidation entries from the response before returning `result`. +pub async fn mizan_call(client: &MizanClient, fn_name: &str, args: Value) -> Result { + let url = Url::parse(&format!("{}/call/", client.config().base_url.trim_end_matches('/'))) + .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?; + let payload = serde_json::json!({ "fn": fn_name, "args": args }); + let body_bytes = serde_json::to_vec(&payload) + .map_err(|e| MizanError::transport(format!("encode: {e}")))?; + let body = request_with_retry(client, Method::POST, url, Some(body_bytes)).await?; + + let response: CallResponse = serde_json::from_str(&body) + .map_err(|e| MizanError::transport(format!("decode: {e}")))?; + + if let Some(merges) = response.merge { + for entry in &merges { + client.context_registry() + .merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value) + .await; + } + } + if let Some(invalidations) = response.invalidate { + for entry in invalidations { + match entry { + InvalidateEntry::Broad(name) => { + client.invalidation_queue().invalidate(name).await; + } + InvalidateEntry::Scoped { context, params } => { + client.invalidation_queue().invalidate_scoped(context, params).await; + } + } + } + } + + Ok(response.result.unwrap_or(Value::Null)) +} + + +async fn request_with_retry( + client: &MizanClient, + method: Method, + url: Url, + body: Option>, +) -> Result { + client.ensure_session_ready().await?; + + let mut last_err: Option = None; + for attempt in 0..MAX_ATTEMPTS { + let headers = client.resolve_headers().await; + let mut req = client.http().request(method.clone(), url.clone()).headers(headers); + if let Some(bytes) = &body { + req = req.header(reqwest::header::CONTENT_TYPE, "application/json") + .body(bytes.clone()); + } + match req.send().await { + Ok(res) => { + let status = res.status().as_u16(); + let text = res.text().await.unwrap_or_default(); + if status < 400 { + return Ok(text); + } + if (400..500).contains(&status) { + return Err(MizanError::from_response(status, text)); + } + last_err = Some(MizanError::from_response(status, text)); + } + Err(e) => { + last_err = Some(MizanError::transport(e.to_string())); + } + } + if attempt + 1 < MAX_ATTEMPTS { + tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt + 1)).await; + } + } + Err(last_err.unwrap_or_else(|| MizanError::transport("retry budget exhausted"))) +} + + +#[derive(Deserialize)] +struct CallResponse { + result: Option, + #[serde(default)] + merge: Option>, + #[serde(default)] + invalidate: Option>, +} + + +#[derive(Deserialize)] +struct MergeEntry { + context: String, + #[serde(default)] + params: Option, + slot: String, + value: Value, +} + + +#[derive(Deserialize)] +#[serde(untagged)] +enum InvalidateEntry { + Broad(String), + Scoped { context: String, params: Value }, +} diff --git a/protocol/mizan-codegen/.gitignore b/protocol/mizan-codegen/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/protocol/mizan-codegen/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/protocol/mizan-codegen/Cargo.lock b/protocol/mizan-codegen/Cargo.lock new file mode 100644 index 0000000..c742feb --- /dev/null +++ b/protocol/mizan-codegen/Cargo.lock @@ -0,0 +1,470 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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 = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mizan-codegen" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "clap", + "indexmap", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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 = "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 = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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 = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/protocol/mizan-codegen/Cargo.toml b/protocol/mizan-codegen/Cargo.toml new file mode 100644 index 0000000..2283755 --- /dev/null +++ b/protocol/mizan-codegen/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mizan-codegen" +version = "0.1.0" +edition = "2021" +description = "Mizan codegen substrate — consumes Mizan IR; emits typed clients for React/Vue/Svelte/Rust/Python." +license = "MIT" + +[[bin]] +name = "mizan-generate" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +askama = "0.12" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +toml = "0.8" +anyhow = "1" +indexmap = { version = "2", features = ["serde"] } diff --git a/protocol/mizan-codegen/src/config.rs b/protocol/mizan-codegen/src/config.rs new file mode 100644 index 0000000..45b8b70 --- /dev/null +++ b/protocol/mizan-codegen/src/config.rs @@ -0,0 +1,119 @@ +//! Codegen configuration — deserialized from `mizan.toml` at the consumer +//! project root. Replaces the JS substrate's `mizan.config.mjs`. +//! +//! Example: +//! +//! ```toml +//! project_id = "blazr-studio" +//! output = "src/api" +//! targets = ["react"] +//! +//! [source.fastapi] +//! module = "blazr_session.handlers" +//! cwd = "../.." +//! command = ["uv", "run", "python"] +//! +//! [rust_kernel] +//! path = "../../mizan/frontends/mizan-rust" +//! ``` + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::Deserialize; + + +#[derive(Debug, Deserialize)] +pub struct Config { + #[serde(default)] + pub project_id: Option, + + #[serde(default = "default_output")] + pub output: PathBuf, + + #[serde(default = "default_targets")] + pub targets: Vec, + + #[serde(default)] + pub source: SourceConfig, + + #[serde(default)] + pub rust_kernel: Option, + + #[serde(default)] + pub rust_crate_name: Option, +} + + +fn default_output() -> PathBuf { + PathBuf::from("src/api") +} + + +fn default_targets() -> Vec { + vec!["react".to_string()] +} + + +#[derive(Debug, Deserialize, Default)] +pub struct SourceConfig { + #[serde(default)] + pub fastapi: Option, + + #[serde(default)] + pub django: Option, +} + + +#[derive(Debug, Deserialize)] +pub struct FastapiSource { + pub module: String, + + #[serde(default)] + pub cwd: Option, + + #[serde(default)] + pub python: Option, + + #[serde(default)] + pub command: Option>, + + #[serde(default)] + pub env: BTreeMap, +} + + +#[derive(Debug, Deserialize)] +pub struct DjangoSource { + pub manage_path: PathBuf, + + #[serde(default)] + pub python: Option, + + #[serde(default)] + pub command: Option>, + + #[serde(default)] + pub env: BTreeMap, +} + + +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum RustKernelSpec { + Path { + path: String, + }, + Git { + git: String, + #[serde(default)] + tag: Option, + #[serde(default)] + rev: Option, + #[serde(default)] + branch: Option, + }, + Version { + version: String, + }, +} diff --git a/protocol/mizan-codegen/src/emit/casing.rs b/protocol/mizan-codegen/src/emit/casing.rs new file mode 100644 index 0000000..5a7f3b7 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/casing.rs @@ -0,0 +1,137 @@ +//! Casing transforms — port of `protocol/mizan-generate/generator/lib/casing.mjs`. +//! +//! The Mizan IR uses snake_case names (`user_id`, `update_profile`). Per-target +//! identifier conventions vary: TypeScript wants `pascalCase`/`camelCase`, +//! Rust wants `snake_case` (with `r#`-escaping for keywords). These helpers +//! pin the conversion so emit-targets share one vocabulary. + + +fn split_parts(s: &str) -> Vec<&str> { + s.split(|c: char| c == '.' || c == '-' || c == '_') + .filter(|p| !p.is_empty()) + .collect() +} + + +fn uppercase_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + + +fn lowercase_first(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + } +} + + +pub fn pascal_case(s: &str) -> String { + split_parts(s).into_iter().map(uppercase_first).collect() +} + + +pub fn camel_case(s: &str) -> String { + let pascal = pascal_case(s); + lowercase_first(&pascal) +} + + +/// Insert underscores at lowercase/digit-to-uppercase boundaries, unify with +/// the existing `.`/`-`/`_` separators, then lowercase + join. +pub fn snake_case(s: &str) -> String { + let mut with_boundaries = String::with_capacity(s.len() + 4); + let mut prev: Option = None; + for c in s.chars() { + if let Some(p) = prev { + if (p.is_ascii_lowercase() || p.is_ascii_digit()) && c.is_ascii_uppercase() { + with_boundaries.push('_'); + } + } + with_boundaries.push(c); + prev = Some(c); + } + split_parts(&with_boundaries) + .into_iter() + .map(|p| p.to_ascii_lowercase()) + .collect::>() + .join("_") +} + + +/// Rust reserved words that can be escaped via `r#` (excludes `crate`, `self`, +/// `Self`, `super`, `extern`, which can't be raw-escaped on stable). +const RUST_RAW_KEYWORDS: &[&str] = &[ + "as", "break", "const", "continue", "else", "enum", "false", "fn", "for", + "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", + "ref", "return", "static", "struct", "trait", "true", "type", "unsafe", + "use", "where", "while", "async", "await", "dyn", "abstract", "become", + "box", "do", "final", "macro", "override", "priv", "typeof", "unsized", + "virtual", "yield", "try", "union", +]; + +const RUST_HARD_RESERVED: &[&str] = &["crate", "self", "Self", "super", "extern"]; + + +pub fn rust_ident(name: &str) -> String { + let snake = snake_case(name); + if RUST_HARD_RESERVED.contains(&snake.as_str()) { + format!("{snake}_") + } else if RUST_RAW_KEYWORDS.contains(&snake.as_str()) { + format!("r#{snake}") + } else { + snake + } +} + + +pub fn rust_type_ident(name: &str) -> String { + let pascal = pascal_case(name); + if RUST_HARD_RESERVED.contains(&pascal.as_str()) { + format!("{pascal}_") + } else if RUST_RAW_KEYWORDS.contains(&pascal.as_str()) { + format!("r#{pascal}") + } else { + pascal + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pascal_case_matches_js_codegen() { + assert_eq!(pascal_case("user_profile"), "UserProfile"); + assert_eq!(pascal_case("find-user"), "FindUser"); + assert_eq!(pascal_case("api.v1.users"), "ApiV1Users"); + assert_eq!(pascal_case(""), ""); + } + + #[test] + fn camel_case_matches_js_codegen() { + assert_eq!(camel_case("user_profile"), "userProfile"); + assert_eq!(camel_case("UpdateProfile"), "updateProfile"); + } + + #[test] + fn snake_case_inserts_pascal_boundaries() { + assert_eq!(snake_case("UserProfile"), "user_profile"); + assert_eq!(snake_case("camelCase"), "camel_case"); + assert_eq!(snake_case("already_snake"), "already_snake"); + assert_eq!(snake_case("HTTPResponse"), "httpresponse"); // matches JS behavior + } + + #[test] + fn rust_ident_escapes_keywords() { + assert_eq!(rust_ident("type"), "r#type"); + assert_eq!(rust_ident("normal"), "normal"); + assert_eq!(rust_ident("self"), "self_"); + } +} diff --git a/protocol/mizan-codegen/src/emit/channels.rs b/protocol/mizan-codegen/src/emit/channels.rs new file mode 100644 index 0000000..531125e --- /dev/null +++ b/protocol/mizan-codegen/src/emit/channels.rs @@ -0,0 +1,163 @@ +//! Channels target — emits `channels.ts` (typed message envelopes + channel +//! registry) and `channels.hooks.tsx` (`useXChannel` React hooks) from the +//! `x-mizan-channels` extension. Django-only feature; the FastAPI backend's +//! IR carries an empty channels list and this target emits nothing. + +use std::path::PathBuf; + +use askama::Template; +use indexmap::IndexMap; + +use crate::config::Config; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::ir::{JsonSchema, MizanChannel, MizanIR}; + + +pub struct ChannelsTarget; + + +impl CodegenTarget for ChannelsTarget { + fn name(&self) -> &'static str { "channels" } + + fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec { + if ir.channels.is_empty() { + return Vec::new(); + } + + let schemas_block = emit_channel_schemas(&ir.channels, &ir.components.schemas); + + let types_content = ChannelsTypes { + channels: ir.channels.iter().map(ChannelView::from_ir).collect(), + schemas_block, + }.render().expect("channels.ts renders"); + + let mut type_imports: Vec = Vec::new(); + for ch in &ir.channels { + if ch.has_params { if let Some(t) = &ch.params_type { type_imports.push(t.clone()); } } + if ch.has_react_message { if let Some(t) = &ch.react_message_type { type_imports.push(t.clone()); } } + if ch.has_django_message { if let Some(t) = &ch.django_message_type { type_imports.push(t.clone()); } } + } + + let hooks_content = ChannelsHooks { + channels: ir.channels.iter().map(ChannelView::from_ir).collect(), + type_imports, + }.render().expect("channels.hooks.tsx renders"); + + vec![ + EmittedFile::new(PathBuf::from("channels.ts"), types_content), + EmittedFile::new(PathBuf::from("channels.hooks.tsx"), hooks_content), + ] + } +} + + +#[derive(Template)] +#[template(path = "channels/channels.ts.j2", escape = "none")] +struct ChannelsTypes<'a> { + channels: Vec>, + schemas_block: String, +} + + +#[derive(Template)] +#[template(path = "channels/channels.hooks.tsx.j2", escape = "none")] +struct ChannelsHooks<'a> { + channels: Vec>, + type_imports: Vec, +} + + +struct ChannelView<'a> { + name: &'a str, + pascal_name: &'a str, + has_params: bool, + has_react_message: bool, + has_django_message: bool, + params_type: String, + react_message_type: String, + django_message_type: String, + params_type_or_record: String, + react_msg_type_or_never: String, + django_msg_type_or_never: String, +} + + +impl<'a> ChannelView<'a> { + fn from_ir(ch: &'a MizanChannel) -> Self { + let params_type = ch.params_type.clone().unwrap_or_default(); + let react_message_type = ch.react_message_type.clone().unwrap_or_default(); + let django_message_type = ch.django_message_type.clone().unwrap_or_default(); + + Self { + name: &ch.name, + pascal_name: &ch.pascal_name, + has_params: ch.has_params, + has_react_message: ch.has_react_message, + has_django_message: ch.has_django_message, + params_type_or_record: if ch.has_params { params_type.clone() } else { "Record".to_string() }, + react_msg_type_or_never: if ch.has_react_message { react_message_type.clone() } else { "never".to_string() }, + django_msg_type_or_never: if ch.has_django_message { django_message_type.clone() } else { "never".to_string() }, + params_type, + react_message_type, + django_message_type, + } + } +} + + +fn emit_channel_schemas( + channels: &[MizanChannel], + schemas: &IndexMap, +) -> String { + let mut blocks: Vec = Vec::new(); + for ch in channels { + for ty in [&ch.params_type, &ch.react_message_type, &ch.django_message_type].iter().filter_map(|t| t.as_ref()) { + if let Some(schema) = schemas.get(ty) { + blocks.push(emit_schema_as_ts(ty, schema)); + } + } + } + blocks.join("\n\n") +} + + +fn emit_schema_as_ts(name: &str, schema: &JsonSchema) -> String { + if let Some(props) = &schema.properties { + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + let fields = props.iter() + .map(|(field_name, field_schema)| { + let opt = if required.contains(field_name.as_str()) { "" } else { "?" }; + let ty = ts_type_expression(field_schema); + format!(" {field_name}{opt}: {ty}") + }) + .collect::>() + .join("\n"); + if fields.is_empty() { + format!("export interface {name} {{}}") + } else { + format!("export interface {name} {{\n{fields}\n}}") + } + } else { + format!("export type {name} = {}", ts_type_expression(schema)) + } +} + + +fn ts_type_expression(schema: &JsonSchema) -> String { + if let Some(ref_name) = schema.ref_name() { + return ref_name.to_string(); + } + match schema.ty.as_deref() { + Some("integer") | Some("number") => "number".to_string(), + Some("boolean") => "boolean".to_string(), + Some("string") => "string".to_string(), + Some("array") => { + let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default())); + format!("{elem}[]") + } + Some("object") => "Record".to_string(), + _ => "unknown".to_string(), + } +} diff --git a/protocol/mizan-codegen/src/emit/mod.rs b/protocol/mizan-codegen/src/emit/mod.rs new file mode 100644 index 0000000..1ee1511 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/mod.rs @@ -0,0 +1,67 @@ +//! Emit substrate — per-target codegen lives here. +//! +//! Every target implements `CodegenTarget` and returns the same shape: +//! a `Vec`. The dispatcher in `main.rs` iterates one target +//! per `--target` flag and writes each `EmittedFile` to disk under the +//! configured output directory. +//! +//! Targets land in subsequent phases; Phase 2 establishes the trait so +//! the dispatch surface is settled before any target's emit logic is +//! written. + +use std::path::PathBuf; + +use crate::config::Config; +use crate::ir::MizanIR; + +pub mod casing; +pub mod channels; +pub mod python; +pub mod react; +pub mod rust; +pub mod stage1; +pub mod svelte; +pub mod vue; + + +pub trait CodegenTarget { + /// Stable identifier — matches the `--target` flag value and the + /// `targets = [...]` entry in `mizan.toml`. + fn name(&self) -> &'static str; + + /// Walk the IR and produce the per-target file set. Each path is + /// relative to the consumer's configured `output` directory. + fn emit(&self, ir: &MizanIR, config: &Config) -> Vec; +} + + +pub struct EmittedFile { + pub rel_path: PathBuf, + pub content: String, +} + + +impl EmittedFile { + pub fn new(rel_path: impl Into, content: impl Into) -> Self { + Self { + rel_path: rel_path.into(), + content: content.into(), + } + } +} + + +/// Look up a registered target by name. Returns `None` for unknown +/// targets so the CLI can warn instead of panicking. +pub fn target_by_name(name: &str) -> Option> { + match name { + "stage1" => Some(Box::new(stage1::Stage1)), + "rust" => Some(Box::new(rust::RustCrate)), + "python" => Some(Box::new(python::PythonClient)), + "react" => Some(Box::new(react::ReactAdapter)), + "vue" => Some(Box::new(vue::VueAdapter)), + "svelte" => Some(Box::new(svelte::SvelteAdapter)), + "channels" => Some(Box::new(channels::ChannelsTarget)), + _ => None, + } +} diff --git a/protocol/mizan-codegen/src/emit/python.rs b/protocol/mizan-codegen/src/emit/python.rs new file mode 100644 index 0000000..77dfd41 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/python.rs @@ -0,0 +1,330 @@ +//! Python target — emits a Pydantic-typed client wrapping the PyO3 +//! extension exposed by `mizan-rust`. +//! +//! Output shape lives at `templates/python/*.j2`. Per-method bodies are +//! pre-rendered in Rust before passing into `client.py.j2` so the template +//! only owns top-level section layout, not Python method-signature details. + +use std::path::PathBuf; + +use askama::Template; +use indexmap::IndexMap; + +use crate::config::Config; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::{pascal_case, rust_ident, snake_case}; +use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR}; + + +pub struct PythonClient; + + +impl CodegenTarget for PythonClient { + fn name(&self) -> &'static str { "python" } + + fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec { + let schemas_block = ir.components.schemas.iter() + .map(|(name, schema)| emit_schema_block(name, schema)) + .collect::>() + .join("\n\n"); + + let types_py = TypesTemplate { schemas_block }.render().expect("types.py renders"); + let client_py = build_client_template(ir).render().expect("client.py renders"); + let init_py = InitTemplate {}.render().expect("__init__.py renders"); + + vec![ + EmittedFile::new(PathBuf::from("types.py"), types_py), + EmittedFile::new(PathBuf::from("client.py"), client_py), + EmittedFile::new(PathBuf::from("__init__.py"), init_py), + ] + } +} + + +#[derive(Template)] +#[template(path = "python/__init__.py.j2", escape = "none")] +struct InitTemplate {} + + +#[derive(Template)] +#[template(path = "python/types.py.j2", escape = "none")] +struct TypesTemplate { + schemas_block: String, +} + + +#[derive(Template)] +#[template(path = "python/client.py.j2", escape = "none")] +struct ClientTemplate { + ctx_methods_block: String, + call_methods_block: String, + data_classes_block: String, +} + + +// ─── types.py schema bodies ──────────────────────────────────────────────── + + +fn emit_schema_block(raw_name: &str, schema: &JsonSchema) -> String { + let name = pascal_case(raw_name); + + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + let literal = values.iter() + .filter_map(|v| v.as_str()) + .map(|v| format!("\"{v}\"")) + .collect::>() + .join(", "); + return format!("{name} = Literal[{literal}]"); + } + } + + if schema.ty.as_deref() == Some("array") { + let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default())); + return format!("{name} = list[{elem}]"); + } + + if schema.ty.as_deref() == Some("object") { + if let Some(props) = &schema.properties { + return emit_pydantic_class(&name, schema, props); + } + } + + let ty = py_type_from_schema(schema); + format!("{name} = {ty}") +} + + +fn emit_pydantic_class( + name: &str, + schema: &JsonSchema, + properties: &IndexMap, +) -> String { + if properties.is_empty() { + return format!("class {name}(BaseModel):\n pass"); + } + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + + let field_lines = properties.iter() + .map(|(field_raw, field_schema)| { + let mut ty = py_type_from_schema(field_schema); + let is_required = required.contains(field_raw.as_str()) + || field_schema.default.is_some(); + if !is_required { + if !ty.ends_with(" | None") { + ty = format!("{ty} | None"); + } + format!(" {}: {ty} = None", rust_ident(field_raw)) + } else { + format!(" {}: {ty}", rust_ident(field_raw)) + } + }) + .collect::>() + .join("\n"); + + format!("class {name}(BaseModel):\n{field_lines}") +} + + +fn py_type_from_schema(schema: &JsonSchema) -> String { + if let Some(ref_name) = schema.ref_name() { + return pascal_case(ref_name); + } + + if let Some(any_of) = &schema.any_of { + let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null")); + let non_null: Vec<&JsonSchema> = any_of + .iter() + .filter(|s| s.ty.as_deref() != Some("null")) + .collect(); + if has_null && non_null.len() == 1 { + return format!("{} | None", py_type_from_schema(non_null[0])); + } + } + + let nullable = schema.nullable; + let inner = inner_py_type(schema); + if nullable { + format!("{inner} | None") + } else { + inner + } +} + + +fn inner_py_type(schema: &JsonSchema) -> String { + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + let parts = values.iter() + .filter_map(|v| v.as_str()) + .map(|v| format!("\"{v}\"")) + .collect::>() + .join(", "); + return format!("Literal[{parts}]"); + } + } + match schema.ty.as_deref() { + Some("integer") => "int".to_string(), + Some("number") => "float".to_string(), + Some("boolean") => "bool".to_string(), + Some("string") => "str".to_string(), + Some("array") => { + let elem = py_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default())); + format!("list[{elem}]") + } + Some("object") => { + if schema.properties.is_some() { "Any".to_string() } + else { "dict[str, Any]".to_string() } + } + _ => "Any".to_string(), + } +} + + +// ─── client.py method blocks ─────────────────────────────────────────────── + + +fn build_client_template(ir: &MizanIR) -> ClientTemplate { + let ctx_methods_block = ir.contexts.iter() + .map(|(ctx_name, ctx_meta)| { + let fetch = emit_fetch_method(ctx_name, ctx_meta); + let subscribe = emit_subscribe_method(ctx_name, ctx_meta); + format!("{fetch}{subscribe}") + }) + .collect::>() + .join("\n"); + + let call_methods_block = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form) + .map(emit_call_method) + .collect::>() + .join("\n"); + + let data_classes_block = ir.contexts.iter() + .map(|(ctx_name, _)| { + let ctx_fns: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| f.is_context.as_str() == Some(ctx_name)) + .collect(); + emit_context_data_class(ctx_name, &ctx_fns) + }) + .collect::>() + .join("\n"); + + ClientTemplate { ctx_methods_block, call_methods_block, data_classes_block } +} + + +fn py_arg_type(json_ty: &str) -> &'static str { + match json_ty { + "integer" => "int", + "number" => "float", + "boolean" => "bool", + _ => "str", + } +} + + +fn emit_fetch_method(ctx_name: &str, ctx_meta: &MizanContext) -> String { + let method_name = format!("fetch_{}_context", snake_case(ctx_name)); + let param_args = ctx_meta.params.iter() + .map(|(n, m)| { + let ident = rust_ident(n); + let ty = py_arg_type(&m.ty); + if m.required { format!("{ident}: {ty}") } + else { format!("{ident}: {ty} | None = None") } + }) + .collect::>() + .join(", "); + let param_dict = if ctx_meta.params.is_empty() { + "{}".to_string() + } else { + let pairs = ctx_meta.params.iter() + .map(|(n, _)| format!("\"{n}\": {}", rust_ident(n))) + .collect::>() + .join(", "); + format!("{{{pairs}}}") + }; + let data_class = format!("{}ContextData", pascal_case(ctx_name)); + let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") }; + + format!( + " def {method_name}(self{arg_sig}) -> \"{data_class}\":\n raw = self._inner.fetch_context(\"{ctx_name}\", {param_dict})\n return {data_class}(**raw)\n", + ) +} + + +fn emit_subscribe_method(ctx_name: &str, ctx_meta: &MizanContext) -> String { + let param_args = ctx_meta.params.iter() + .map(|(n, m)| { + let ident = rust_ident(n); + let ty = py_arg_type(&m.ty); + if m.required { format!("{ident}: {ty}") } + else { format!("{ident}: {ty} | None = None") } + }) + .collect::>() + .join(", "); + let param_dict = if ctx_meta.params.is_empty() { + "{}".to_string() + } else { + let pairs = ctx_meta.params.iter() + .map(|(n, _)| format!("\"{n}\": {}", rust_ident(n))) + .collect::>() + .join(", "); + format!("{{{pairs}}}") + }; + let arg_sig = if param_args.is_empty() { String::new() } else { format!(", {param_args}") }; + let snake = snake_case(ctx_name); + let indent_39 = " ".repeat(39); + + format!( + " def subscribe_{snake}_context(self{arg_sig},\n{indent_39}callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription:\n return self._inner.subscribe_context(\"{ctx_name}\", {param_dict}, callback)\n", + ) +} + + +fn emit_call_method(fn_meta: &MizanFunction) -> String { + let method_name = format!("call_{}", snake_case(&fn_meta.name)); + let pascal_output = pascal_case(&fn_meta.output_type); + + let input_arg = if fn_meta.has_input { + let it = fn_meta.input_type.as_deref().unwrap_or(""); + format!(", args: {}", pascal_case(it)) + } else { + String::new() + }; + let args_expr = if fn_meta.has_input { "args.model_dump()" } else { "{}" }; + let return_type = if fn_meta.output_nullable { + format!("{pascal_output} | None") + } else { + pascal_output.clone() + }; + let decode_expr = if fn_meta.output_nullable { + format!("{pascal_output}(**raw) if raw is not None else None") + } else { + format!("{pascal_output}(**raw)") + }; + + format!( + " def {method_name}(self{input_arg}) -> {return_type}:\n raw = self._inner.call(\"{wire}\", {args_expr})\n return {decode_expr}\n", + wire = fn_meta.name, + ) +} + + +fn emit_context_data_class(ctx_name: &str, ctx_fns: &[&MizanFunction]) -> String { + let class_name = format!("{}ContextData", pascal_case(ctx_name)); + let field_lines = ctx_fns.iter() + .map(|fn_meta| { + let pascal_out = pascal_case(&fn_meta.output_type); + let ty = if fn_meta.output_nullable { format!("{pascal_out} | None") } else { pascal_out }; + format!(" {}: {ty}", rust_ident(&fn_meta.name)) + }) + .collect::>() + .join("\n"); + format!( + "class {class_name}(BaseModel):\n \"\"\"Bundled return of fetch_{snake}_context.\"\"\"\n{field_lines}\n", + snake = snake_case(ctx_name), + ) +} diff --git a/protocol/mizan-codegen/src/emit/react.rs b/protocol/mizan-codegen/src/emit/react.rs new file mode 100644 index 0000000..80ebfd2 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/react.rs @@ -0,0 +1,142 @@ +//! React target — Stage 2 emit on top of Stage 1. Wraps each registered +//! context in a React Provider so kernel subscription happens once per +//! provider mount; consumer hooks read from React Context. +//! +//! Output shape lives at `templates/react/react.tsx.j2`. + +use std::path::PathBuf; + +use askama::Template; + +use crate::config::Config; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::pascal_case; +use crate::ir::{IsContext, MizanFunction, MizanIR}; + + +pub struct ReactAdapter; + + +impl CodegenTarget for ReactAdapter { + fn name(&self) -> &'static str { "react" } + + fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec { + let content = build_template(ir).render().expect("react template renders"); + vec![EmittedFile::new(PathBuf::from("react.tsx"), content)] + } +} + + +#[derive(Template)] +#[template(path = "react/react.tsx.j2", escape = "none")] +struct ReactTemplate<'a> { + has_global: bool, + stage1_imports: Vec, + global_fns: Vec>, + named_contexts: Vec>, + calls: Vec, +} + + +struct HookRender<'a> { + pascal: String, + output_type: &'a str, + name: &'a str, +} + + +struct CtxRender<'a> { + pascal: String, + name: &'a str, + has_params: bool, + fns: Vec>, +} + + +struct CallRender { + pascal: String, + has_input: bool, +} + + +fn dedupe_preserving_order(items: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + items.into_iter().filter(|s| seen.insert(s.clone())).collect() +} + + +fn build_template(ir: &MizanIR) -> ReactTemplate<'_> { + let has_global = ir.contexts.contains_key("global"); + + let global_fns: Vec = ir.functions.iter() + .filter(|f| f.is_context.as_str() == Some("global")) + .map(|f| HookRender { + pascal: pascal_case(&f.camel_name), + output_type: &f.output_type, + name: &f.name, + }) + .collect(); + + let named_contexts: Vec = ir.contexts.iter() + .filter(|(n, _)| n.as_str() != "global") + .map(|(ctx_name, ctx_meta)| { + let ctx_fns: Vec = ir.functions.iter() + .filter(|f| f.is_context.as_str() == Some(ctx_name.as_str())) + .map(|f| HookRender { + pascal: pascal_case(&f.camel_name), + output_type: &f.output_type, + name: &f.name, + }) + .collect(); + CtxRender { + pascal: pascal_case(ctx_name), + name: ctx_name, + has_params: !ctx_meta.params.is_empty(), + fns: ctx_fns, + } + }) + .collect(); + + let mutations: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty()) + .collect(); + let plain_fns: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty()) + .collect(); + + let calls: Vec = mutations.iter().chain(plain_fns.iter()) + .map(|f| CallRender { + pascal: pascal_case(&f.camel_name), + has_input: f.has_input, + }) + .collect(); + + let mut stage1: Vec = Vec::new(); + for ctx_name in ir.contexts.keys() { + let p = pascal_case(ctx_name); + stage1.push(format!("fetch{p}Context")); + stage1.push(format!("type {p}ContextData")); + stage1.push(format!("type {p}ContextParams")); + } + for fn_meta in mutations.iter().chain(plain_fns.iter()) { + stage1.push(format!("call{}", pascal_case(&fn_meta.camel_name))); + } + let context_fns: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| !matches!(f.is_context, IsContext::No)) + .collect(); + let output_types = dedupe_preserving_order( + context_fns.iter().map(|f| f.output_type.clone()), + ); + for t in output_types { + stage1.push(format!("type {t}")); + } + + ReactTemplate { + has_global, + stage1_imports: stage1, + global_fns, + named_contexts, + calls, + } +} diff --git a/protocol/mizan-codegen/src/emit/rust.rs b/protocol/mizan-codegen/src/emit/rust.rs new file mode 100644 index 0000000..b1959dc --- /dev/null +++ b/protocol/mizan-codegen/src/emit/rust.rs @@ -0,0 +1,474 @@ +//! Rust target — emits a complete Cargo crate consuming the +//! `mizan-rust` kernel. Output shape lives at `templates/rust/*.j2`. + +use std::path::PathBuf; + +use askama::Template; +use indexmap::IndexMap; + +use crate::config::{Config, RustKernelSpec}; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::{pascal_case, rust_ident, rust_type_ident, snake_case}; +use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR}; + + +pub struct RustCrate; + + +impl CodegenTarget for RustCrate { + fn name(&self) -> &'static str { "rust" } + + fn emit(&self, ir: &MizanIR, config: &Config) -> Vec { + let crate_name = config + .rust_crate_name + .clone() + .unwrap_or_else(|| "mizan_client".to_string()); + + let kernel_dep = format_kernel_dep(config.rust_kernel.as_ref()); + + let mut out: Vec = Vec::new(); + + out.push(EmittedFile::new( + "Cargo.toml", + CargoTemplate { crate_name: &crate_name, kernel_dep: &kernel_dep } + .render().expect("Cargo.toml renders"), + )); + + out.push(EmittedFile::new("src/types.rs", emit_types_rs(&ir.components.schemas))); + + let mut context_modules: Vec = Vec::new(); + for (ctx_name, ctx_meta) in &ir.contexts { + let module_name = snake_case(ctx_name); + out.push(EmittedFile::new( + PathBuf::from("src/contexts").join(format!("{module_name}.rs")), + emit_context_file(ctx_name, ctx_meta, &ir.functions), + )); + context_modules.push(module_name); + } + if !context_modules.is_empty() { + out.push(EmittedFile::new("src/contexts/mod.rs", emit_mod_file(&context_modules))); + } + + let mut mutation_modules: Vec = Vec::new(); + let mut function_modules: Vec = Vec::new(); + for fn_meta in &ir.functions { + if !matches!(fn_meta.is_context, IsContext::No) || fn_meta.is_form { continue; } + let is_mutation = !fn_meta.affects.is_empty(); + let kind = if is_mutation { "mutations" } else { "functions" }; + let module_name = snake_case(&fn_meta.camel_name); + out.push(EmittedFile::new( + PathBuf::from(format!("src/{kind}")).join(format!("{module_name}.rs")), + emit_call_file(fn_meta), + )); + if is_mutation { + mutation_modules.push(module_name); + } else { + function_modules.push(module_name); + } + } + if !mutation_modules.is_empty() { + out.push(EmittedFile::new("src/mutations/mod.rs", emit_mod_file(&mutation_modules))); + } + if !function_modules.is_empty() { + out.push(EmittedFile::new("src/functions/mod.rs", emit_mod_file(&function_modules))); + } + + out.push(EmittedFile::new( + "src/lib.rs", + LibTemplate { + has_contexts: !context_modules.is_empty(), + has_mutations: !mutation_modules.is_empty(), + has_functions: !function_modules.is_empty(), + }.render().expect("lib.rs renders"), + )); + + out + } +} + + +#[derive(Template)] +#[template(path = "rust/Cargo.toml.j2", escape = "none")] +struct CargoTemplate<'a> { + crate_name: &'a str, + kernel_dep: &'a str, +} + + +#[derive(Template)] +#[template(path = "rust/lib.rs.j2", escape = "none")] +struct LibTemplate { + has_contexts: bool, + has_mutations: bool, + has_functions: bool, +} + + +#[derive(Template)] +#[template(path = "rust/mod.rs.j2", escape = "none")] +struct ModTemplate { + modules: Vec, +} + + +#[derive(Template)] +#[template(path = "rust/context.rs.j2", escape = "none")] +struct ContextTemplate<'a> { + pascal: String, + snake: String, + ctx_name: &'a str, + type_imports: Vec, + data_fields: Vec, + params: Vec, +} + + +#[derive(Template)] +#[template(path = "rust/call.rs.j2", escape = "none")] +struct CallTemplate<'a> { + snake: String, + name: &'a str, + return_type: String, + type_imports: Vec, + input_param: String, + args_value: &'static str, +} + + +#[derive(Template)] +#[template(path = "rust/types.rs.j2", escape = "none")] +struct TypesTemplate { + schemas_block: String, + hoisted_enums_block: String, +} + + +struct StructField { + raw_name: String, + ident: String, + ty: String, + has_rename: bool, +} + + +fn dedupe_preserving_order(items: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + items.into_iter().filter(|s| seen.insert(s.clone())).collect() +} + + +// ─── Cargo.toml ──────────────────────────────────────────────────────────── + + +fn format_kernel_dep(spec: Option<&RustKernelSpec>) -> String { + match spec { + Some(RustKernelSpec::Path { path }) => format!("{{ path = {} }}", json_str(path)), + Some(RustKernelSpec::Git { git, tag, rev, branch }) => { + let mut parts = vec![format!("git = {}", json_str(git))]; + if let Some(t) = tag { parts.push(format!("tag = {}", json_str(t))); } + if let Some(r) = rev { parts.push(format!("rev = {}", json_str(r))); } + if let Some(b) = branch { parts.push(format!("branch = {}", json_str(b))); } + format!("{{ {} }}", parts.join(", ")) + } + Some(RustKernelSpec::Version { version }) => format!("{{ version = {} }}", json_str(version)), + None => "{ version = \"0.1\" }".to_string(), + } +} + + +fn json_str(s: &str) -> String { + serde_json::to_string(s).expect("string literal serializes") +} + + +// ─── mod.rs ──────────────────────────────────────────────────────────────── + + +fn emit_mod_file(module_names: &[String]) -> String { + let mut sorted = module_names.to_vec(); + sorted.sort(); + ModTemplate { modules: sorted }.render().expect("mod.rs renders") +} + + +// ─── Context file ────────────────────────────────────────────────────────── + + +fn emit_context_file( + ctx_name: &str, + ctx_meta: &MizanContext, + all_functions: &[MizanFunction], +) -> String { + let pascal = pascal_case(ctx_name); + let snake = snake_case(ctx_name); + + let ctx_fns: Vec<&MizanFunction> = all_functions + .iter() + .filter(|f| f.is_context.as_str() == Some(ctx_name)) + .collect(); + + let type_imports = dedupe_preserving_order( + ctx_fns.iter().map(|f| rust_type_ident(&f.output_type)), + ); + + let data_fields: Vec = ctx_fns.iter() + .map(|f| { + let ident = rust_ident(&f.name); + StructField { + has_rename: ident != f.name, + raw_name: f.name.clone(), + ident, + ty: rust_type_ident(&f.output_type), + } + }) + .collect(); + + let params: Vec = ctx_meta.params.iter() + .map(|(p_name, p_meta)| { + let ident = rust_ident(p_name); + let base = param_rust_type(&p_meta.ty); + let ty = if p_meta.required { base.to_string() } else { format!("Option<{base}>") }; + StructField { + has_rename: ident != *p_name, + raw_name: p_name.clone(), + ident, + ty, + } + }) + .collect(); + + ContextTemplate { + pascal, + snake, + ctx_name, + type_imports, + data_fields, + params, + }.render().expect("context.rs renders") +} + + +fn param_rust_type(json_ty: &str) -> &'static str { + match json_ty { + "integer" => "i64", + "number" => "f64", + "boolean" => "bool", + _ => "String", + } +} + + +// ─── Call file ───────────────────────────────────────────────────────────── + + +fn emit_call_file(fn_meta: &MizanFunction) -> String { + let output_type = rust_type_ident(&fn_meta.output_type); + let return_type = if fn_meta.output_nullable { + format!("Option<{output_type}>") + } else { + output_type.clone() + }; + + let input_type = fn_meta.input_type.as_deref().map(rust_type_ident); + + let mut used_seed: Vec = vec![output_type.clone()]; + if let Some(t) = &input_type { used_seed.push(t.clone()); } + let type_imports = dedupe_preserving_order(used_seed); + + let (input_param, args_value) = if fn_meta.has_input { + let it = input_type.as_deref().unwrap_or(""); + ( + format!(", args: &{it}"), + "serde_json::to_value(args).unwrap_or(Value::Object(Default::default()))", + ) + } else { + ( + String::new(), + "Value::Object(Default::default())", + ) + }; + + CallTemplate { + snake: snake_case(&fn_meta.name), + name: &fn_meta.name, + return_type, + type_imports, + input_param, + args_value, + }.render().expect("call.rs renders") +} + + +// ─── types.rs ────────────────────────────────────────────────────────────── + + +struct EnumCtx { + hoisted: Vec<(String, Vec)>, + depth: usize, + enum_name: Option, +} + + +fn emit_types_rs(schemas: &IndexMap) -> String { + let mut ctx = EnumCtx { hoisted: Vec::new(), depth: 0, enum_name: None }; + + let schemas_block = schemas.iter() + .map(|(raw_name, schema)| { + let name = rust_type_ident(raw_name); + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + return emit_string_enum(&name, values); + } + } + if schema.ty.as_deref() == Some("array") { + return emit_transparent_array(&name, schema, &mut ctx); + } + if schema.ty.as_deref() == Some("object") { + if let Some(props) = &schema.properties { + return emit_struct(&name, schema, props, &mut ctx); + } + } + emit_type_alias(&name, schema, &mut ctx) + }) + .collect::>() + .join("\n"); + + let hoisted_enums_block = ctx.hoisted.iter() + .map(|(n, v)| emit_string_enum(n, v)) + .collect::>() + .join("\n"); + + TypesTemplate { schemas_block, hoisted_enums_block } + .render().expect("types.rs renders") +} + + +fn emit_string_enum(name: &str, variants: &[serde_json::Value]) -> String { + let body = variants.iter() + .filter_map(|v| v.as_str()) + .map(|v| { + let ident = pascal_case(v); + let rename = if ident == v { + String::new() + } else { + format!(" #[serde(rename = {})]\n", json_str(v)) + }; + format!("{rename} {ident},") + }) + .collect::>() + .join("\n"); + format!( + "#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub enum {name} {{\n{body}\n}}\n", + name = rust_type_ident(name), + ) +} + + +fn emit_transparent_array(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String { + ctx.depth = 1; + ctx.enum_name = None; + let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx); + format!( + "#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(transparent)]\npub struct {name}(pub Vec<{inner}>);\n", + ) +} + + +fn emit_struct( + name: &str, + schema: &JsonSchema, + properties: &IndexMap, + ctx: &mut EnumCtx, +) -> String { + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + + let fields = properties.iter() + .map(|(field_raw, field_schema)| { + let field_name = rust_ident(field_raw); + ctx.depth = 1; + ctx.enum_name = Some(format!("{name}_{}", pascal_case(field_raw))); + let mut ty = rust_type_from_schema(field_schema, ctx); + let is_required = required.contains(field_raw.as_str()) + || field_schema.default.is_some(); + if !is_required && !ty.starts_with("Option<") { + ty = format!("Option<{ty}>"); + } + let rename = if field_name == *field_raw { + String::new() + } else { + format!(" #[serde(rename = \"{field_raw}\")]\n") + }; + format!("{rename} pub {field_name}: {ty},") + }) + .collect::>() + .join("\n"); + + format!( + "#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {name} {{\n{fields}\n}}\n", + ) +} + + +fn emit_type_alias(name: &str, schema: &JsonSchema, ctx: &mut EnumCtx) -> String { + ctx.depth = 0; + ctx.enum_name = Some(name.to_string()); + let ty = rust_type_from_schema(schema, ctx); + format!("pub type {name} = {ty};\n") +} + + +fn rust_type_from_schema(schema: &JsonSchema, ctx: &mut EnumCtx) -> String { + if let Some(r) = schema.ref_name() { + return rust_type_ident(r); + } + + if let Some(any_of) = &schema.any_of { + let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null")); + let non_null: Vec<&JsonSchema> = any_of + .iter() + .filter(|s| s.ty.as_deref() != Some("null")) + .collect(); + if has_null && non_null.len() == 1 { + ctx.enum_name = None; + return format!("Option<{}>", rust_type_from_schema(non_null[0], ctx)); + } + } + + let nullable = schema.nullable; + let inner = inner_rust_type(schema, ctx); + if nullable { + format!("Option<{inner}>") + } else { + inner + } +} + + +fn inner_rust_type(schema: &JsonSchema, ctx: &mut EnumCtx) -> String { + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + let enum_name = ctx + .enum_name + .clone() + .unwrap_or_else(|| format!("Enum_{}", ctx.depth)); + ctx.hoisted.push((enum_name.clone(), values.clone())); + return enum_name; + } + } + match schema.ty.as_deref() { + Some("integer") => "i64".to_string(), + Some("number") => "f64".to_string(), + Some("boolean") => "bool".to_string(), + Some("string") => "String".to_string(), + Some("array") => { + ctx.depth += 1; + ctx.enum_name = None; + let inner = rust_type_from_schema(schema.items.as_deref().unwrap_or(&JsonSchema::default()), ctx); + format!("Vec<{inner}>") + } + Some("object") => "serde_json::Value".to_string(), + _ => "serde_json::Value".to_string(), + } +} diff --git a/protocol/mizan-codegen/src/emit/stage1.rs b/protocol/mizan-codegen/src/emit/stage1.rs new file mode 100644 index 0000000..a7b954e --- /dev/null +++ b/protocol/mizan-codegen/src/emit/stage1.rs @@ -0,0 +1,369 @@ +//! Stage 1 — framework-agnostic TypeScript emission. +//! +//! Output mirrors `protocol/mizan-generate/generator/lib/stage1.mjs`: +//! +//! types.ts — typed declarations for every Pydantic model +//! contexts/.ts — `fetchContext(params)` per context group +//! mutations/.ts — `call(args)` per mutation +//! functions/.ts — `call(args)` per plain function +//! index.ts — re-exports +//! +//! The deterministic per-function/per-context files match the JS codegen +//! byte-for-byte against an identical IR; types.ts emits Pydantic schemas +//! directly as TS interfaces instead of routing through openapi-typescript. +//! Consumers import by name from index.ts so the structural shape of +//! types.ts is not load-bearing — only the named exports are. + +use std::path::PathBuf; + +use askama::Template; +use indexmap::IndexMap; + +use crate::config::Config; +use crate::ir::{IsContext, JsonSchema, MizanContext, MizanFunction, MizanIR}; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::pascal_case; + + +#[derive(Template)] +#[template(path = "stage1/call.ts.j2", escape = "none")] +struct CallTemplate<'a> { + pascal: &'a str, + name: &'a str, + has_input: bool, + input_type: &'a str, + output_type: &'a str, + type_imports: Vec, +} + + +#[derive(Template)] +#[template(path = "stage1/context.ts.j2", escape = "none")] +struct ContextTemplate<'a> { + pascal: &'a str, + ctx_name: &'a str, + type_imports: Vec, + data_fields: Vec>, + has_params: bool, + params: Vec>, +} + + +struct ContextDataField<'a> { + name: &'a str, + output_type: &'a str, +} + + +struct ContextParamField<'a> { + name: &'a str, + ts_type: &'static str, + required: bool, +} + + +#[derive(Template)] +#[template(path = "stage1/index.ts.j2", escape = "none")] +struct IndexTemplate<'a> { + contexts: Vec>, + calls: Vec>, + framework_adapters: Vec<&'static str>, +} + + +struct IndexContext<'a> { + pascal: String, + name: &'a str, +} + + +struct IndexCall<'a> { + pascal: String, + camel_name: &'a str, + dir: &'static str, +} + + +pub struct Stage1; + + +impl CodegenTarget for Stage1 { + fn name(&self) -> &'static str { "stage1" } + + fn emit(&self, ir: &MizanIR, config: &Config) -> Vec { + let mut out: Vec = Vec::new(); + + out.push(EmittedFile::new("types.ts", emit_types(&ir.components.schemas))); + + for (ctx_name, ctx_meta) in &ir.contexts { + let content = emit_context_file(ctx_name, ctx_meta, &ir.functions); + out.push(EmittedFile::new( + PathBuf::from("contexts").join(format!("{ctx_name}.ts")), + content, + )); + } + + for fn_meta in regular_functions(&ir.functions) { + let dir = if fn_meta.affects.is_empty() { "functions" } else { "mutations" }; + let content = emit_call_file(fn_meta); + out.push(EmittedFile::new( + PathBuf::from(dir).join(format!("{}.ts", fn_meta.camel_name)), + content, + )); + } + + out.push(EmittedFile::new("index.ts", emit_stage1_index(ir, config))); + + out + } +} + + +fn regular_functions(functions: &[MizanFunction]) -> impl Iterator { + functions.iter().filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form) +} + + +fn dedupe_preserving_order(items: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + items.into_iter().filter(|s| seen.insert(s.clone())).collect() +} + + +// ─── Per-context file ────────────────────────────────────────────────────── + + +fn emit_context_file( + ctx_name: &str, + ctx_meta: &MizanContext, + all_functions: &[MizanFunction], +) -> String { + let pascal = pascal_case(ctx_name); + let ctx_fns: Vec<&MizanFunction> = all_functions + .iter() + .filter(|f| f.is_context.as_str() == Some(ctx_name)) + .collect(); + + let type_imports = dedupe_preserving_order( + ctx_fns.iter().map(|f| f.output_type.clone()), + ); + + let data_fields: Vec = ctx_fns + .iter() + .map(|f| ContextDataField { name: &f.name, output_type: &f.output_type }) + .collect(); + + let params: Vec = ctx_meta.params.iter() + .map(|(name, meta)| ContextParamField { + name, + ts_type: json_ty_to_ts(&meta.ty), + required: meta.required, + }) + .collect(); + + let template = ContextTemplate { + pascal: &pascal, + ctx_name, + type_imports, + data_fields, + has_params: !ctx_meta.params.is_empty(), + params, + }; + template.render().expect("context template renders") +} + + +fn json_ty_to_ts(json_ty: &str) -> &'static str { + match json_ty { + "integer" | "number" => "number", + "boolean" => "boolean", + _ => "string", + } +} + + +// ─── Per-function (call) file — same shape for mutations + plain ────────── + + +fn emit_call_file(fn_meta: &MizanFunction) -> String { + let pascal = pascal_case(&fn_meta.camel_name); + + let mut imports: Vec = Vec::new(); + if fn_meta.has_input { + if let Some(t) = &fn_meta.input_type { imports.push(t.clone()); } + } + imports.push(fn_meta.output_type.clone()); + let type_imports = dedupe_preserving_order(imports); + + let template = CallTemplate { + pascal: &pascal, + name: &fn_meta.name, + has_input: fn_meta.has_input, + input_type: fn_meta.input_type.as_deref().unwrap_or(""), + output_type: &fn_meta.output_type, + type_imports, + }; + template.render().expect("call template renders") +} + + +// ─── Stage 1 index ───────────────────────────────────────────────────────── + + +fn emit_stage1_index(ir: &MizanIR, config: &Config) -> String { + let contexts: Vec = ir.contexts.keys() + .map(|ctx_name| IndexContext { pascal: pascal_case(ctx_name), name: ctx_name }) + .collect(); + + let calls: Vec = regular_functions(&ir.functions) + .map(|fn_meta| IndexCall { + pascal: pascal_case(&fn_meta.camel_name), + camel_name: &fn_meta.camel_name, + dir: if fn_meta.affects.is_empty() { "functions" } else { "mutations" }, + }) + .collect(); + + // Stage 2 single-file frontend adapters get re-exported from index.ts so + // consumers can `import { MizanContext, useEcho } from './api'`. + let framework_adapters: Vec<&'static str> = ["react", "vue", "svelte"].iter() + .copied() + .filter(|t| config.targets.iter().any(|cfg_t| cfg_t == t)) + .collect(); + + IndexTemplate { contexts, calls, framework_adapters } + .render().expect("index template renders") +} + + +// ─── types.ts ────────────────────────────────────────────────────────────── + + +fn emit_types(schemas: &IndexMap) -> String { + let mut out = String::new(); + out.push_str("// AUTO-GENERATED by mizan — do not edit\n\n"); + for (raw_name, schema) in schemas { + out.push_str(&emit_schema_decl(raw_name, schema)); + out.push('\n'); + } + out +} + + +fn emit_schema_decl(name: &str, schema: &JsonSchema) -> String { + // String enum → union of string literals. + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + let union = values + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(" | "); + return format!("export type {name} = {union}\n"); + } + } + + // Top-level array → array alias. + if schema.ty.as_deref() == Some("array") { + let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default())); + return format!("export type {name} = {elem}[]\n"); + } + + // Object with properties → interface declaration. + if schema.ty.as_deref() == Some("object") { + if let Some(props) = &schema.properties { + return emit_interface(name, schema, props); + } + } + + // Fallback — alias to a structural expression. + let expr = ts_type_expression(schema); + format!("export type {name} = {expr}\n") +} + + +fn emit_interface( + name: &str, + schema: &JsonSchema, + properties: &IndexMap, +) -> String { + let required: std::collections::HashSet<&str> = + schema.required.iter().map(String::as_str).collect(); + + let fields = properties + .iter() + .map(|(field_name, field_schema)| { + // Fields are non-optional if they're explicitly required OR + // if they carry a default value (server always populates). + let is_required = required.contains(field_name.as_str()) + || field_schema.default.is_some(); + let opt = if is_required { "" } else { "?" }; + let ty = ts_type_expression(field_schema); + format!(" {field_name}{opt}: {ty}") + }) + .collect::>() + .join("\n"); + + if fields.is_empty() { + format!("export interface {name} {{}}\n") + } else { + format!("export interface {name} {{\n{fields}\n}}\n") + } +} + + +fn ts_type_expression(schema: &JsonSchema) -> String { + // `$ref` → bare type name reference into components.schemas. + if let Some(ref_name) = schema.ref_name() { + return ref_name.to_string(); + } + + // `anyOf` with a null variant → `T | null`. + if let Some(any_of) = &schema.any_of { + let has_null = any_of.iter().any(|s| s.ty.as_deref() == Some("null")); + let non_null: Vec<&JsonSchema> = any_of + .iter() + .filter(|s| s.ty.as_deref() != Some("null")) + .collect(); + if has_null && non_null.len() == 1 { + return format!("{} | null", ts_type_expression(non_null[0])); + } + let union = any_of + .iter() + .map(ts_type_expression) + .collect::>() + .join(" | "); + return union; + } + + if let Some(values) = &schema.r#enum { + if schema.ty.as_deref() == Some("string") { + return values + .iter() + .filter_map(|v| v.as_str()) + .map(|s| format!("\"{s}\"")) + .collect::>() + .join(" | "); + } + } + + let base = match schema.ty.as_deref() { + Some("integer") | Some("number") => "number".to_string(), + Some("boolean") => "boolean".to_string(), + Some("string") => "string".to_string(), + Some("array") => { + let elem = ts_type_expression(schema.items.as_deref().unwrap_or(&JsonSchema::default())); + format!("{elem}[]") + } + Some("object") => "Record".to_string(), + Some("null") => "null".to_string(), + _ => "unknown".to_string(), + }; + + if schema.nullable { + format!("{base} | null") + } else { + base + } +} diff --git a/protocol/mizan-codegen/src/emit/svelte.rs b/protocol/mizan-codegen/src/emit/svelte.rs new file mode 100644 index 0000000..6f294d6 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/svelte.rs @@ -0,0 +1,78 @@ +//! Svelte target — readable store per context, re-export per call. +//! Output shape lives at `templates/svelte/svelte.ts.j2`. + +use std::path::PathBuf; + +use askama::Template; + +use crate::config::Config; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::pascal_case; +use crate::ir::{IsContext, MizanIR}; + + +pub struct SvelteAdapter; + + +impl CodegenTarget for SvelteAdapter { + fn name(&self) -> &'static str { "svelte" } + + fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec { + let content = build_template(ir).render().expect("svelte template renders"); + vec![EmittedFile::new(PathBuf::from("svelte.ts"), content)] + } +} + + +#[derive(Template)] +#[template(path = "svelte/svelte.ts.j2", escape = "none")] +struct SvelteTemplate<'a> { + stage1_imports: Vec, + contexts: Vec>, + call_exports: Vec, +} + + +struct CtxRender<'a> { + pascal: String, + name: &'a str, + has_params: bool, + params_arg: &'static str, +} + + +fn build_template(ir: &MizanIR) -> SvelteTemplate<'_> { + let contexts: Vec = ir.contexts.iter() + .map(|(ctx_name, ctx_meta)| { + let has_params = !ctx_meta.params.is_empty(); + CtxRender { + pascal: pascal_case(ctx_name), + name: ctx_name, + has_params, + params_arg: if has_params { "params" } else { "{} as any" }, + } + }) + .collect(); + + let mutations = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty()); + let plain_fns = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty()); + let call_exports: Vec = mutations.chain(plain_fns) + .map(|f| pascal_case(&f.camel_name)) + .collect(); + + let mut stage1: Vec = Vec::new(); + for ctx_name in ir.contexts.keys() { + let p = pascal_case(ctx_name); + stage1.push(format!("fetch{p}Context")); + stage1.push(format!("type {p}ContextData")); + stage1.push(format!("type {p}ContextParams")); + } + for c in &call_exports { + stage1.push(format!("call{c}")); + } + + SvelteTemplate { stage1_imports: stage1, contexts, call_exports } +} diff --git a/protocol/mizan-codegen/src/emit/vue.rs b/protocol/mizan-codegen/src/emit/vue.rs new file mode 100644 index 0000000..86077a6 --- /dev/null +++ b/protocol/mizan-codegen/src/emit/vue.rs @@ -0,0 +1,107 @@ +//! Vue target — composable per context + composable per call. +//! Output shape lives at `templates/vue/vue.ts.j2`. + +use std::path::PathBuf; + +use askama::Template; + +use crate::config::Config; +use crate::emit::CodegenTarget; +use crate::emit::EmittedFile; +use crate::emit::casing::pascal_case; +use crate::ir::{IsContext, MizanFunction, MizanIR}; + + +pub struct VueAdapter; + + +impl CodegenTarget for VueAdapter { + fn name(&self) -> &'static str { "vue" } + + fn emit(&self, ir: &MizanIR, _config: &Config) -> Vec { + let content = build_template(ir).render().expect("vue template renders"); + vec![EmittedFile::new(PathBuf::from("vue.ts"), content)] + } +} + + +#[derive(Template)] +#[template(path = "vue/vue.ts.j2", escape = "none")] +struct VueTemplate<'a> { + stage1_imports: Vec, + contexts: Vec>, + calls: Vec, +} + + +struct CtxRender<'a> { + pascal: String, + name: &'a str, + has_params: bool, + params_arg: &'static str, + fns: Vec>, +} + + +struct FnRender<'a> { + camel_name: &'a str, + name: &'a str, + output_type: &'a str, +} + + +struct CallRender { + pascal: String, + has_input: bool, +} + + +fn build_template(ir: &MizanIR) -> VueTemplate<'_> { + let contexts: Vec = ir.contexts.iter() + .map(|(ctx_name, ctx_meta)| { + let has_params = !ctx_meta.params.is_empty(); + let ctx_fns: Vec = ir.functions.iter() + .filter(|f| f.is_context.as_str() == Some(ctx_name.as_str())) + .map(|f| FnRender { + camel_name: &f.camel_name, + name: &f.name, + output_type: &f.output_type, + }) + .collect(); + CtxRender { + pascal: pascal_case(ctx_name), + name: ctx_name, + has_params, + params_arg: if has_params { "params" } else { "{} as any" }, + fns: ctx_fns, + } + }) + .collect(); + + let mutations: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && !f.affects.is_empty()) + .collect(); + let plain_fns: Vec<&MizanFunction> = ir.functions.iter() + .filter(|f| matches!(f.is_context, IsContext::No) && !f.is_form && f.affects.is_empty()) + .collect(); + + let calls: Vec = mutations.iter().chain(plain_fns.iter()) + .map(|f| CallRender { + pascal: pascal_case(&f.camel_name), + has_input: f.has_input, + }) + .collect(); + + let mut stage1: Vec = Vec::new(); + for ctx_name in ir.contexts.keys() { + let p = pascal_case(ctx_name); + stage1.push(format!("fetch{p}Context")); + stage1.push(format!("type {p}ContextData")); + stage1.push(format!("type {p}ContextParams")); + } + for fn_meta in mutations.iter().chain(plain_fns.iter()) { + stage1.push(format!("call{}", pascal_case(&fn_meta.camel_name))); + } + + VueTemplate { stage1_imports: stage1, contexts, calls } +} diff --git a/protocol/mizan-codegen/src/fetch.rs b/protocol/mizan-codegen/src/fetch.rs new file mode 100644 index 0000000..b63b7da --- /dev/null +++ b/protocol/mizan-codegen/src/fetch.rs @@ -0,0 +1,149 @@ +//! Schema fetching — spawns the configured backend's schema-export command +//! and deserializes its stdout into a typed `MizanIR`. +//! +//! Two backends recognized today: +//! - FastAPI: `python -m mizan_fastapi.cli ` +//! - Django: `python manage.py export_mizan_schema --indent 0` +//! +//! The fetcher reads stdout, skips any banner text before the first `{`, +//! and parses the remainder as JSON. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; + +use crate::config::{Config, DjangoSource, FastapiSource}; +use crate::ir::MizanIR; + + +pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result { + let raw = if let Some(fa) = &config.source.fastapi { + run_fastapi(fa, config_dir)? + } else if let Some(dj) = &config.source.django { + run_django(dj, config_dir)? + } else { + return Err(anyhow!( + "config.source must declare either [source.fastapi] or [source.django]" + )); + }; + + parse_ir(&raw) +} + + +fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result { + let cwd = match &src.cwd { + Some(rel) => config_dir.join(rel), + None => config_dir.to_path_buf(), + }; + + let (program, mut args) = resolve_command(&src.command, &src.python); + args.extend([ + "-m".to_string(), + "mizan_fastapi.cli".to_string(), + src.module.clone(), + ]); + + run_subprocess(&program, &args, &cwd, &src.env, "FastAPI schema export") +} + + +fn run_django(src: &DjangoSource, config_dir: &Path) -> Result { + let manage_path = config_dir.join(&src.manage_path); + let manage_dir = manage_path + .parent() + .ok_or_else(|| anyhow!("django manage_path has no parent: {}", manage_path.display()))? + .to_path_buf(); + + let (program, mut args) = resolve_command(&src.command, &src.python); + + // If the user supplied an explicit command (e.g. `uv run python`), they + // expect to invoke from the manage_dir without a path prefix on manage.py. + // Otherwise we pass the absolute manage_path so the python interpreter + // doesn't depend on cwd. + if src.command.is_some() { + args.push("manage.py".to_string()); + } else { + args.push(manage_path.to_string_lossy().into_owned()); + } + args.extend([ + "export_mizan_schema".to_string(), + "--indent".to_string(), + "0".to_string(), + ]); + + run_subprocess(&program, &args, &manage_dir, &src.env, "Django schema export") +} + + +fn resolve_command( + explicit: &Option>, + python_override: &Option, +) -> (String, Vec) { + if let Some(cmd) = explicit { + let (head, tail) = cmd.split_first().expect("command must be non-empty"); + return (head.clone(), tail.to_vec()); + } + let python = python_override.as_deref().unwrap_or("python"); + (python.to_string(), Vec::new()) +} + + +fn run_subprocess( + program: &str, + args: &[String], + cwd: &Path, + env: &std::collections::BTreeMap, + label: &str, +) -> Result { + let mut cmd = Command::new(program); + cmd.args(args).current_dir(cwd); + for (k, v) in env { + cmd.env(k, v); + } + + let output = cmd + .output() + .with_context(|| format!("spawning {label} ({program})"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(anyhow!( + "{label} failed (exit {:?})\n--- stderr ---\n{stderr}\n--- stdout ---\n{stdout}", + output.status.code(), + )); + } + + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("{label}: non-UTF-8 stdout"))?; + Ok(stdout) +} + + +fn parse_ir(raw: &str) -> Result { + let json_start = raw + .find('{') + .ok_or_else(|| anyhow!("no JSON object found in schema-export output"))?; + serde_json::from_str(&raw[json_start..]).context("deserializing Mizan IR from schema JSON") +} + + +/// Library helper for tests: deserialize an IR from a pre-fetched JSON string +/// (no subprocess). Mirrors `parse_ir` but exposed for crate-external callers. +pub fn parse_ir_from_str(json: &str) -> Result { + parse_ir(json) +} + + +/// Library helper: resolve a path relative to the config directory, returning +/// an absolute path. Consumers may want this when constructing output paths. +pub fn resolve_path(config_dir: &Path, p: impl Into) -> PathBuf { + let p = p.into(); + if p.is_absolute() { + p + } else { + config_dir.join(p) + } +} diff --git a/protocol/mizan-codegen/src/ir.rs b/protocol/mizan-codegen/src/ir.rs new file mode 100644 index 0000000..1187aa9 --- /dev/null +++ b/protocol/mizan-codegen/src/ir.rs @@ -0,0 +1,248 @@ +//! Mizan IR — strongly-typed deserialization of the backends' schema export. +//! +//! Every Mizan backend (Django, FastAPI, mizan-ts) emits the same OpenAPI +//! document with three load-bearing extension fields: +//! - `x-mizan-functions` — array of function entries +//! - `x-mizan-contexts` — map of context groups +//! - `components.schemas` — OpenAPI Pydantic→JSONSchema per Input/Output +//! +//! The structs here deserialize that JSON envelope into typed Rust values +//! the emit targets walk. The OpenAPI document body (paths, info, etc.) is +//! intentionally not modeled — the codegen consumes only the extensions. + +use std::collections::BTreeMap; + +use indexmap::IndexMap; +use serde::Deserialize; + + +#[derive(Debug, Deserialize)] +pub struct MizanIR { + #[serde(rename = "x-mizan-functions", default)] + pub functions: Vec, + + #[serde(rename = "x-mizan-contexts", default)] + pub contexts: IndexMap, + + /// Django-only channel registrations. FastAPI backends emit an empty list. + #[serde(rename = "x-mizan-channels", default)] + pub channels: Vec, + + #[serde(default)] + pub components: Components, +} + + +#[derive(Debug, Deserialize, Clone)] +pub struct MizanChannel { + pub name: String, + #[serde(rename = "pascalName")] + pub pascal_name: String, + #[serde(rename = "hasParams", default)] + pub has_params: bool, + #[serde(rename = "hasReactMessage", default)] + pub has_react_message: bool, + #[serde(rename = "hasDjangoMessage", default)] + pub has_django_message: bool, + #[serde(rename = "paramsType", default)] + pub params_type: Option, + #[serde(rename = "reactMessageType", default)] + pub react_message_type: Option, + #[serde(rename = "djangoMessageType", default)] + pub django_message_type: Option, +} + + +#[derive(Debug, Deserialize, Clone)] +pub struct MizanFunction { + pub name: String, + + #[serde(rename = "camelName")] + pub camel_name: String, + + #[serde(rename = "hasInput")] + pub has_input: bool, + + #[serde(rename = "inputType")] + pub input_type: Option, + + #[serde(rename = "outputType")] + pub output_type: String, + + #[serde(rename = "outputNullable", default)] + pub output_nullable: bool, + + pub transport: Transport, + + #[serde(rename = "isContext", default)] + pub is_context: IsContext, + + #[serde(rename = "isForm", default)] + pub is_form: bool, + + #[serde(rename = "formName", default)] + pub form_name: Option, + + #[serde(rename = "formRole", default)] + pub form_role: Option, + + #[serde(default)] + pub affects: Vec, + + /// Names of contexts whose state is patched by this function's return + /// body via the kernel's `splice_slot` merger. Empty when the function + /// is not a merge target. + #[serde(default)] + pub merge: Vec, +} + + +#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Transport { + #[default] + Http, + Websocket, + Both, +} + + +/// IR-level `isContext` value. The backends emit `false` for non-context +/// functions and a string (`"global"`, `"user"`, …) for context-grouped +/// functions. Custom Deserialize bridges the boolean/string union into a +/// typed Rust enum. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum IsContext { + #[default] + No, + Yes(String), +} + +impl IsContext { + pub fn as_str(&self) -> Option<&str> { + match self { + IsContext::No => None, + IsContext::Yes(s) => Some(s.as_str()), + } + } +} + +impl<'de> Deserialize<'de> for IsContext { + fn deserialize(de: D) -> Result + where + D: serde::Deserializer<'de>, + { + let v = serde_json::Value::deserialize(de)?; + match v { + serde_json::Value::Bool(false) => Ok(IsContext::No), + serde_json::Value::Bool(true) => Err(serde::de::Error::custom( + "isContext: bare `true` is not a valid context name", + )), + serde_json::Value::String(s) => Ok(IsContext::Yes(s)), + serde_json::Value::Null => Ok(IsContext::No), + other => Err(serde::de::Error::custom(format!( + "isContext: expected `false` or string, got {other:?}" + ))), + } + } +} + + +#[derive(Debug, Deserialize, Clone)] +pub struct AffectTarget { + #[serde(rename = "type")] + pub kind: AffectKind, + pub name: String, + #[serde(default)] + pub context: Option, +} + + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AffectKind { + Context, + Function, +} + + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct MizanContext { + #[serde(default)] + pub functions: Vec, + #[serde(default)] + pub params: IndexMap, +} + + +#[derive(Debug, Deserialize, Clone)] +pub struct ContextParam { + #[serde(rename = "type")] + pub ty: String, + + pub required: bool, + + #[serde(rename = "sharedBy", default)] + pub shared_by: Vec, +} + + +#[derive(Debug, Deserialize, Default)] +pub struct Components { + #[serde(default)] + pub schemas: IndexMap, +} + + +/// JSON Schema subset used by the emit targets. Mirrors the surface the +/// existing JS adapters traverse (`$ref`, `anyOf`, `enum`, `type`, `items`, +/// `properties`, `required`, `nullable`). Unknown fields are stashed in +/// `extra` so backends can include schema annotations the codegen ignores. +#[derive(Debug, Deserialize, Default, Clone)] +pub struct JsonSchema { + #[serde(rename = "type", default)] + pub ty: Option, + + #[serde(rename = "$ref", default)] + pub r#ref: Option, + + #[serde(rename = "enum", default)] + pub r#enum: Option>, + + #[serde(rename = "anyOf", default)] + pub any_of: Option>, + + #[serde(default)] + pub nullable: bool, + + #[serde(default)] + pub items: Option>, + + #[serde(default)] + pub properties: Option>, + + #[serde(default)] + pub required: Vec, + + #[serde(rename = "additionalProperties", default)] + pub additional_properties: Option, + + /// Presence of this field means the schema has a default — the server + /// always populates it. Consumers can treat the field as non-optional + /// even if it's absent from `required`. + #[serde(default)] + pub default: Option, + + #[serde(flatten)] + pub extra: BTreeMap, +} + + +impl JsonSchema { + /// `$ref: "#/components/schemas/Foo"` → `Some("Foo")`. + pub fn ref_name(&self) -> Option<&str> { + self.r#ref + .as_deref() + .and_then(|s| s.strip_prefix("#/components/schemas/")) + } +} diff --git a/protocol/mizan-codegen/src/lib.rs b/protocol/mizan-codegen/src/lib.rs new file mode 100644 index 0000000..f7118f8 --- /dev/null +++ b/protocol/mizan-codegen/src/lib.rs @@ -0,0 +1,10 @@ +//! Mizan codegen — library surface for tests and tooling. +//! +//! The binary `mizan-generate` (src/main.rs) is the consumer entry point; +//! the library re-exports IR / config / fetch / emit so integration tests +//! can drive the substrate without spawning the binary. + +pub mod config; +pub mod emit; +pub mod fetch; +pub mod ir; diff --git a/protocol/mizan-codegen/src/main.rs b/protocol/mizan-codegen/src/main.rs new file mode 100644 index 0000000..1551115 --- /dev/null +++ b/protocol/mizan-codegen/src/main.rs @@ -0,0 +1,164 @@ +//! `mizan-generate` — Rust codegen binary. +//! +//! Replaces the Node-based `protocol/mizan-generate/generator/cli.mjs`. +//! Reads `mizan.toml`, spawns the configured backend to fetch the IR, and +//! dispatches each `--target` to its `CodegenTarget` impl. Per-target file +//! emission writes under the configured `output` directory. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::Parser; + +use mizan_codegen::{config, emit, fetch}; + + +#[derive(Parser, Debug)] +#[command( + name = "mizan-generate", + about = "Mizan code generator — consumes Mizan IR; emits typed clients.", +)] +struct Cli { + /// Path to the codegen config file. + #[arg(short, long, default_value = "mizan.toml")] + config: PathBuf, + + /// Output directory (overrides `output` in config). + #[arg(short, long)] + output: Option, + + /// Comma-separated list of targets (overrides `targets` in config). + #[arg(short, long)] + target: Option, + + /// Read the IR from a JSON file instead of spawning the backend's + /// schema-export command. The fixture path used by integration tests. + #[arg(long)] + from_json: Option, +} + + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let config_exists = cli.config.exists(); + let mut config: config::Config = if config_exists { + let config_text = fs::read_to_string(&cli.config) + .with_context(|| format!("reading config: {}", cli.config.display()))?; + toml::from_str(&config_text) + .with_context(|| format!("parsing TOML: {}", cli.config.display()))? + } else if cli.from_json.is_some() { + // --from-json bypasses the fetcher, so a missing config is fine — + // CLI flags supply output + targets. + config::Config { + project_id: None, + output: PathBuf::from("."), + targets: vec![], + source: Default::default(), + rust_kernel: None, + rust_crate_name: None, + } + } else { + return Err(anyhow::anyhow!( + "config not found: {} (pass --from-json to skip fetch)", + cli.config.display(), + )); + }; + + if let Some(o) = cli.output { + config.output = o; + } + if let Some(t) = cli.target { + config.targets = t.split(',').map(|s| s.trim().to_string()).collect(); + } + + let config_dir = if config_exists { + resolve_config_dir(&cli.config)? + } else { + std::env::current_dir()? + }; + + let ir = if let Some(json_path) = &cli.from_json { + let abs = if json_path.is_absolute() { + json_path.clone() + } else { + config_dir.join(json_path) + }; + eprintln!("[mizan] Reading IR from {}", abs.display()); + let raw = fs::read_to_string(&abs) + .with_context(|| format!("read {}", abs.display()))?; + fetch::parse_ir_from_str(&raw)? + } else { + eprintln!("[mizan] Fetching schema..."); + fetch::fetch_schema(&config, &config_dir)? + }; + + eprintln!( + "[mizan] Loaded {} function(s), {} context group(s), {} schema(s)", + ir.functions.len(), + ir.contexts.len(), + ir.components.schemas.len(), + ); + + // Stage 1 is the framework-agnostic foundation that react/vue/svelte + // import from. Auto-include it whenever any consumer of `./index` + // (the Stage 1 re-export root) is in the target set. + let needs_stage1 = config.targets.iter() + .any(|t| matches!(t.as_str(), "react" | "vue" | "svelte")); + if needs_stage1 && !config.targets.iter().any(|t| t == "stage1") { + config.targets.insert(0, "stage1".to_string()); + } + // Channels schema piggybacks on the main schema (x-mizan-channels); + // auto-include the channels emit when react is the target and the + // schema actually carries channels. + if config.targets.iter().any(|t| t == "react") + && !ir.channels.is_empty() + && !config.targets.iter().any(|t| t == "channels") + { + config.targets.push("channels".to_string()); + } + + eprintln!("[mizan] Targets: {}", config.targets.join(", ")); + + let output_dir = if config.output.is_absolute() { + config.output.clone() + } else { + config_dir.join(&config.output) + }; + + for target_name in &config.targets { + let Some(target) = emit::target_by_name(target_name) else { + eprintln!("[mizan] WARN: target '{target_name}' has no emitter yet (Phase 2 scaffold)"); + continue; + }; + let files = target.emit(&ir, &config); + for file in files { + let path = output_dir.join(&file.rel_path); + write_output(&path, &file.content)?; + eprintln!("[mizan] {} -> {}", target.name(), file.rel_path.display()); + } + } + + eprintln!("[mizan] Generation complete."); + Ok(()) +} + + +fn resolve_config_dir(config_path: &Path) -> Result { + let abs = fs::canonicalize(config_path) + .with_context(|| format!("canonicalize {}", config_path.display()))?; + Ok(abs + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from("."))) +} + + +fn write_output(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("mkdir {}", parent.display()))?; + } + fs::write(path, content).with_context(|| format!("write {}", path.display())) +} diff --git a/protocol/mizan-codegen/templates/channels/channels.hooks.tsx.j2 b/protocol/mizan-codegen/templates/channels/channels.hooks.tsx.j2 new file mode 100644 index 0000000..54f64b0 --- /dev/null +++ b/protocol/mizan-codegen/templates/channels/channels.hooks.tsx.j2 @@ -0,0 +1,26 @@ +'use client' + +// AUTO-GENERATED by mizan — do not edit + +import { useChannel, type ChannelSubscription } from 'mizan/channels' + +{% if !type_imports.is_empty() -%} +import type { {{ type_imports|join(", ") }} } from './channels' + +{% endif -%} +// ── Channel Hooks ───────────────────────────────────────────────────────── + +{% for ch in channels -%} +/** + * Hook for the {{ ch.name }} channel. + */ +{% if ch.has_params -%} +export function use{{ ch.pascal_name }}Channel(params: {{ ch.params_type_or_record }}): ChannelSubscription<{{ ch.params_type_or_record }}, {{ ch.django_msg_type_or_never }}, {{ ch.react_msg_type_or_never }}> { + return useChannel('{{ ch.name }}', params) +} +{% else -%} +export function use{{ ch.pascal_name }}Channel(): ChannelSubscription, {{ ch.django_msg_type_or_never }}, {{ ch.react_msg_type_or_never }}> { + return useChannel('{{ ch.name }}', {}) +} +{% endif %} +{% endfor -%} diff --git a/protocol/mizan-codegen/templates/channels/channels.ts.j2 b/protocol/mizan-codegen/templates/channels/channels.ts.j2 new file mode 100644 index 0000000..677188b --- /dev/null +++ b/protocol/mizan-codegen/templates/channels/channels.ts.j2 @@ -0,0 +1,26 @@ +// AUTO-GENERATED by mizan — do not edit + +{{ schemas_block }} +// ── Channel Registry ────────────────────────────────────────────────────── + +export const CHANNELS = { +{%- for ch in channels %} + {{ ch.name }}: { + name: '{{ ch.name }}', + pascalName: '{{ ch.pascal_name }}', + hasParams: {{ ch.has_params }}, + hasReactMessage: {{ ch.has_react_message }}, + hasDjangoMessage: {{ ch.has_django_message }}, +{%- if ch.has_params %} + paramsType: '{{ ch.params_type }}', +{%- endif %} +{%- if ch.has_react_message %} + reactMessageType: '{{ ch.react_message_type }}', +{%- endif %} +{%- if ch.has_django_message %} + djangoMessageType: '{{ ch.django_message_type }}', +{%- endif %} + }, +{%- endfor %} +} as const + diff --git a/protocol/mizan-codegen/templates/python/__init__.py.j2 b/protocol/mizan-codegen/templates/python/__init__.py.j2 new file mode 100644 index 0000000..dc55c53 --- /dev/null +++ b/protocol/mizan-codegen/templates/python/__init__.py.j2 @@ -0,0 +1,5 @@ +# AUTO-GENERATED by mizan — do not edit + +from .client import MizanClient # noqa: F401 +from .types import * # noqa: F401, F403 + diff --git a/protocol/mizan-codegen/templates/python/client.py.j2 b/protocol/mizan-codegen/templates/python/client.py.j2 new file mode 100644 index 0000000..eb1fc6d --- /dev/null +++ b/protocol/mizan-codegen/templates/python/client.py.j2 @@ -0,0 +1,39 @@ +# AUTO-GENERATED by mizan — do not edit + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +# Built from frontends/mizan-rust with `maturin develop --features pyo3`. +from mizan_rust import PyMizanClient, PyContextSubscription + +from .types import * # noqa: F401, F403 +from .types import BaseModel # re-import for the synthesized ContextData classes + + +class MizanClient: + """Typed Python facade over the PyO3 mizan-rust kernel.""" + + def __init__(self, base_url: str, *, session: bool = False, + csrf_cookie_name: str = "csrftoken", + csrf_header_name: str = "X-CSRFToken") -> None: + self._inner = PyMizanClient( + base_url, + session=session, + csrf_cookie_name=csrf_cookie_name, + csrf_header_name=csrf_header_name, + ) + +{{ ctx_methods_block }} +{{ call_methods_block }} + def invalidate(self, context: str) -> None: + self._inner.invalidate(context) + + def invalidate_scoped(self, context: str, params: dict[str, Any]) -> None: + self._inner.invalidate_scoped(context, params) + + +# ── Context data shapes (per-context bundle) ────────────────────────────── + +{{ data_classes_block }} diff --git a/protocol/mizan-codegen/templates/python/types.py.j2 b/protocol/mizan-codegen/templates/python/types.py.j2 new file mode 100644 index 0000000..2d233b1 --- /dev/null +++ b/protocol/mizan-codegen/templates/python/types.py.j2 @@ -0,0 +1,10 @@ +# AUTO-GENERATED by mizan — do not edit + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel + +{{ schemas_block }} + diff --git a/protocol/mizan-codegen/templates/react/react.tsx.j2 b/protocol/mizan-codegen/templates/react/react.tsx.j2 new file mode 100644 index 0000000..8895b19 --- /dev/null +++ b/protocol/mizan-codegen/templates/react/react.tsx.j2 @@ -0,0 +1,180 @@ +'use client' + +// AUTO-GENERATED by mizan — do not edit + +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react' +import { + configure, + initSession, + mizanCall, + mizanFetch, + MizanError, + registerContext, + type ContextState, +} from '@mizan/base' + +{% if !stage1_imports.is_empty() -%} +import { {{ stage1_imports|join(", ") }} } from './index' + +{% endif -%} +// Internal — runs inside a Provider, registers with the kernel exactly once. +function useContextSubscription( + name: string, + params: Record, + fetchFn: () => Promise, + initialData?: T, +): ContextState { + const ref = useRef | null>(null) + if (!ref.current) { + ref.current = registerContext(name, params, fetchFn, initialData) + } + const handle = ref.current + + useEffect(() => { + if (handle.getState().status === 'idle') handle.refetch() + return () => handle.unregister() + }, [handle]) + + return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState) +} + +// Internal — wraps an imperative call() with isPending / error state. +interface MutationHook { + mutate: (args: TArgs) => Promise + isPending: boolean + error: Error | null +} + +function useMutation( + callFn: (args: TArgs) => Promise, +): MutationHook { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const mutate = useCallback(async (args: TArgs) => { + setIsPending(true) + setError(null) + try { + return await callFn(args) + } catch (e) { + setError(e as Error) + throw e + } finally { + setIsPending(false) + } + }, [callFn]) + + return { mutate, isPending, error } +} +{% if has_global %} +// ── Global Context ── + +const GlobalCtx = createContext | null>(null) + +export function GlobalContextProvider({ children }: { children: ReactNode }) { + const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : undefined + const state = useContextSubscription('global', {}, () => fetchGlobalContext({} as any), ssrData) + return {children} +} + +export function useGlobalContext(): ContextState { + const ctx = useContext(GlobalCtx) + if (!ctx) throw new Error('useGlobalContext requires or ') + return ctx +} +{% for fn in global_fns %} +export function use{{ fn.pascal }}(): {{ fn.output_type }} | null { + return useGlobalContext().data?.{{ fn.name }} ?? null +} +{% endfor -%} +{% endif -%} +{% for ctx in named_contexts %} +// ── {{ ctx.pascal }} Context ── + +const {{ ctx.pascal }}Ctx = createContext | null>(null) + +{% if ctx.has_params -%} +export function {{ ctx.pascal }}Context({ children, ...params }: {{ ctx.pascal }}ContextParams & { children: ReactNode }) { + const state = useContextSubscription('{{ ctx.name }}', params, () => fetch{{ ctx.pascal }}Context(params)) + return <{{ ctx.pascal }}Ctx.Provider value={state}>{children} +} +{% else -%} +export function {{ ctx.pascal }}Context({ children }: { children: ReactNode }) { + const state = useContextSubscription('{{ ctx.name }}', {}, () => fetch{{ ctx.pascal }}Context({} as any)) + return <{{ ctx.pascal }}Ctx.Provider value={state}>{children} +} +{% endif %} +export function use{{ ctx.pascal }}Context(): ContextState<{{ ctx.pascal }}ContextData> { + const ctx = useContext({{ ctx.pascal }}Ctx) + if (!ctx) throw new Error('use{{ ctx.pascal }}Context requires <{{ ctx.pascal }}Context>') + return ctx +} +{% for fn in ctx.fns %} +export function use{{ fn.pascal }}(): {{ fn.output_type }} | null { + return use{{ ctx.pascal }}Context().data?.{{ fn.name }} ?? null +} +{% endfor -%} +{% endfor -%} +{% for call in calls %} +{% if call.has_input -%} +export function use{{ call.pascal }}() { + return useMutation[0], Awaited>>(call{{ call.pascal }}) +} +{% else -%} +export function use{{ call.pascal }}() { + return useMutation>>(() => call{{ call.pascal }}() as any) +} +{% endif -%} +{% endfor %} +// ── MizanContext root provider ── + +export interface MizanContextProps { + /** Base URL for protocol endpoints. Defaults to "/api/mizan". */ + baseUrl?: string + /** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */ + session?: boolean + children: ReactNode +} + +/** + * Root provider — calls configure() once and mounts the global context (if defined). + * Must wrap any component using Mizan-generated hooks. + */ +export function MizanContext({ baseUrl, session, children }: MizanContextProps) { + const configured = useRef(false) + if (!configured.current) { + const opts: Parameters[0] = {} + if (baseUrl !== undefined) opts.baseUrl = baseUrl + if (session !== undefined) opts.session = session + if (Object.keys(opts).length > 0) configure(opts) + configured.current = true + } +{%- if has_global %} + return {children} +{%- else %} + return <>{children} +{%- endif %} +} + +// ── Imperative escape hatch ── + +/** + * Returns the imperative kernel API. For test harnesses or rare cases where + * a typed generated hook does not fit. Most app code should use the typed hooks. + */ +export function useMizan() { + return { call: mizanCall, fetch: mizanFetch } +} + +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' + diff --git a/protocol/mizan-codegen/templates/rust/Cargo.toml.j2 b/protocol/mizan-codegen/templates/rust/Cargo.toml.j2 new file mode 100644 index 0000000..32c4380 --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/Cargo.toml.j2 @@ -0,0 +1,11 @@ +[package] +name = "{{ crate_name }}" +version = "0.1.0" +edition = "2021" + +[dependencies] +mizan-rust = {{ kernel_dep }} +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "macros"] } + diff --git a/protocol/mizan-codegen/templates/rust/call.rs.j2 b/protocol/mizan-codegen/templates/rust/call.rs.j2 new file mode 100644 index 0000000..e4da810 --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/call.rs.j2 @@ -0,0 +1,17 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +{% if !type_imports.is_empty() -%} +use crate::types::{ {{- type_imports|join(", ") -}} }; + +{% endif -%} +pub async fn call_{{ snake }}(client: &MizanClient{{ input_param }}) -> Result<{{ return_type }}, MizanError> { + let args_value = {{ args_value }}; + let raw = client.call("{{ name }}", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode {{ name }} result: {e}"))) +} + diff --git a/protocol/mizan-codegen/templates/rust/context.rs.j2 b/protocol/mizan-codegen/templates/rust/context.rs.j2 new file mode 100644 index 0000000..da0352d --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/context.rs.j2 @@ -0,0 +1,37 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +{% if !type_imports.is_empty() -%} +use crate::types::{ {{- type_imports|join(", ") -}} }; + +{% endif -%} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct {{ pascal }}ContextData { +{% for field in data_fields -%} +{% if field.has_rename %} #[serde(rename = "{{ field.raw_name }}")] +{% endif %} pub {{ field.ident }}: {{ field.ty }}, +{% endfor -%} +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct {{ pascal }}ContextParams { +{% for p in params -%} +{% if p.has_rename %} #[serde(rename = "{{ p.raw_name }}")] +{% endif %} pub {{ p.ident }}: {{ p.ty }}, +{% endfor -%} +} + +pub async fn fetch_{{ snake }}_context( + client: &MizanClient, + params: &{{ pascal }}ContextParams, +) -> Result<{{ pascal }}ContextData, MizanError> { + let params_value = serde_json::to_value(params).unwrap_or(Value::Object(Default::default())); + let raw = client.fetch_context("{{ ctx_name }}", ¶ms_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode {{ ctx_name }} context: {e}"))) +} + diff --git a/protocol/mizan-codegen/templates/rust/lib.rs.j2 b/protocol/mizan-codegen/templates/rust/lib.rs.j2 new file mode 100644 index 0000000..73b0663 --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/lib.rs.j2 @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod types; +{% if has_contexts %}pub mod contexts; +{% endif %}{% if has_mutations %}pub mod mutations; +{% endif %}{% if has_functions %}pub mod functions; +{% endif %} +pub use mizan_rust::{MizanClient, MizanConfig, MizanError}; + diff --git a/protocol/mizan-codegen/templates/rust/mod.rs.j2 b/protocol/mizan-codegen/templates/rust/mod.rs.j2 new file mode 100644 index 0000000..e953794 --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/mod.rs.j2 @@ -0,0 +1,5 @@ +// AUTO-GENERATED by mizan — do not edit + +{% for name in modules -%} +pub mod {{ name }}; +{% endfor %} diff --git a/protocol/mizan-codegen/templates/rust/types.rs.j2 b/protocol/mizan-codegen/templates/rust/types.rs.j2 new file mode 100644 index 0000000..6d68675 --- /dev/null +++ b/protocol/mizan-codegen/templates/rust/types.rs.j2 @@ -0,0 +1,8 @@ +// AUTO-GENERATED by mizan — do not edit + +#![allow(non_camel_case_types)] + +use serde::{Deserialize, Serialize}; + +{{ schemas_block }} +{{ hoisted_enums_block }} diff --git a/protocol/mizan-codegen/templates/stage1/call.ts.j2 b/protocol/mizan-codegen/templates/stage1/call.ts.j2 new file mode 100644 index 0000000..979ae71 --- /dev/null +++ b/protocol/mizan-codegen/templates/stage1/call.ts.j2 @@ -0,0 +1,17 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +{% if !type_imports.is_empty() -%} +import type { {{ type_imports|join(", ") }} } from '../types' + +{% endif -%} +{% if has_input -%} +export function call{{ pascal }}(args: {{ input_type }}): Promise<{{ output_type }}> { + return mizanCall('{{ name }}', args) +} +{% else -%} +export function call{{ pascal }}(): Promise<{{ output_type }}> { + return mizanCall('{{ name }}', {}) +} +{% endif %} diff --git a/protocol/mizan-codegen/templates/stage1/context.ts.j2 b/protocol/mizan-codegen/templates/stage1/context.ts.j2 new file mode 100644 index 0000000..2ff0a7f --- /dev/null +++ b/protocol/mizan-codegen/templates/stage1/context.ts.j2 @@ -0,0 +1,28 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanFetch } from '@mizan/base' + +{% if !type_imports.is_empty() -%} +import type { {{ type_imports|join(", ") }} } from '../types' + +{% endif -%} +export interface {{ pascal }}ContextData { +{%- for field in data_fields %} + {{ field.name }}: {{ field.output_type }} +{%- endfor %} +} + +{% if has_params -%} +export interface {{ pascal }}ContextParams { +{%- for p in params %} + {{ p.name }}{% if !p.required %}?{% endif %}: {{ p.ts_type }} +{%- endfor %} +} +{%- else -%} +export type {{ pascal }}ContextParams = Record +{%- endif %} + +export function fetch{{ pascal }}Context(params: {{ pascal }}ContextParams): Promise<{{ pascal }}ContextData> { + return mizanFetch('{{ ctx_name }}', params) +} + diff --git a/protocol/mizan-codegen/templates/stage1/index.ts.j2 b/protocol/mizan-codegen/templates/stage1/index.ts.j2 new file mode 100644 index 0000000..ad6c431 --- /dev/null +++ b/protocol/mizan-codegen/templates/stage1/index.ts.j2 @@ -0,0 +1,20 @@ +// AUTO-GENERATED by mizan — do not edit + +export * from './types' +{% if !contexts.is_empty() %} +{%- for ctx in contexts %} +export { fetch{{ ctx.pascal }}Context, type {{ ctx.pascal }}ContextData, type {{ ctx.pascal }}ContextParams } from './contexts/{{ ctx.name }}' +{%- endfor %} +{% endif -%} +{% if !calls.is_empty() %} +{%- for call in calls %} +export { call{{ call.pascal }} } from './{{ call.dir }}/{{ call.camel_name }}' +{%- endfor %} +{% endif -%} +{% if !framework_adapters.is_empty() %} +// Stage 2 framework adapter +{%- for name in framework_adapters %} +export * from './{{ name }}' +{%- endfor %} +{% endif -%} + diff --git a/protocol/mizan-codegen/templates/svelte/svelte.ts.j2 b/protocol/mizan-codegen/templates/svelte/svelte.ts.j2 new file mode 100644 index 0000000..afa5f43 --- /dev/null +++ b/protocol/mizan-codegen/templates/svelte/svelte.ts.j2 @@ -0,0 +1,31 @@ +// AUTO-GENERATED by mizan — do not edit + +import { readable, type Readable } from 'svelte/store' +import { registerContext, type ContextState } from '@mizan/base' + +{% if !stage1_imports.is_empty() -%} +import { {{ stage1_imports|join(", ") }} } from '../index' + +{% endif -%} +{% for ctx in contexts -%} +export function create{{ ctx.pascal }}Context({% if ctx.has_params %}params: {{ ctx.pascal }}ContextParams{% endif %}) { + const store = readable>( + { data: null, status: 'idle', error: null }, + (set) => { + const handle = registerContext('{{ ctx.name }}', {{ ctx.params_arg }}, () => fetch{{ ctx.pascal }}Context({{ ctx.params_arg }})) + const unsub = handle.subscribe(() => set(handle.getState())) + handle.refetch() + return () => { unsub(); handle.unregister() } + }, + ) + + return store +} + +{% endfor -%} +{% for call in call_exports -%} +export { call{{ call }} } from '../index' +{% endfor %} +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' + diff --git a/protocol/mizan-codegen/templates/vue/vue.ts.j2 b/protocol/mizan-codegen/templates/vue/vue.ts.j2 new file mode 100644 index 0000000..26fcfbd --- /dev/null +++ b/protocol/mizan-codegen/templates/vue/vue.ts.j2 @@ -0,0 +1,65 @@ +// AUTO-GENERATED by mizan — do not edit + +import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue' +import { registerContext, type ContextState } from '@mizan/base' + +{% if !stage1_imports.is_empty() -%} +import { {{ stage1_imports|join(", ") }} } from '../index' + +{% endif -%} +{% for ctx in contexts -%} +export function use{{ ctx.pascal }}Context({% if ctx.has_params %}params: {{ ctx.pascal }}ContextParams{% endif %}) { + const state = ref>({ data: null, status: 'idle', error: null }) + let handle: ReturnType | null = null + + onMounted(() => { + handle = registerContext('{{ ctx.name }}', {{ ctx.params_arg }}, () => fetch{{ ctx.pascal }}Context({{ ctx.params_arg }})) + handle.subscribe(() => { state.value = handle!.getState() }) + handle.refetch() + }) + + onServerPrefetch(async () => { + handle = registerContext('{{ ctx.name }}', {{ ctx.params_arg }}, () => fetch{{ ctx.pascal }}Context({{ ctx.params_arg }})) + await handle.refetch() + state.value = handle.getState() + }) + + onUnmounted(() => { handle?.unregister() }) + + return { + state, +{%- for fn in ctx.fns %} + {{ fn.camel_name }}: computed(() => state.value.data?.{{ fn.name }} ?? null) as ComputedRef<{{ fn.output_type }} | null>, +{%- endfor %} + loading: computed(() => state.value.status === 'loading'), + error: computed(() => state.value.error), + } +} + +{% endfor -%} +{% for call in calls -%} +export function use{{ call.pascal }}() { + const isPending = ref(false) + const error = ref(null) +{%- if call.has_input %} + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await call{{ call.pascal }}(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } +{%- else %} + async function mutate() { + isPending.value = true; error.value = null + try { return await call{{ call.pascal }}() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } +{%- endif %} + return { mutate, isPending, error } +} + +{% endfor -%} +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' + diff --git a/protocol/mizan-codegen/tests/channels_smoke.rs b/protocol/mizan-codegen/tests/channels_smoke.rs new file mode 100644 index 0000000..48eacf9 --- /dev/null +++ b/protocol/mizan-codegen/tests/channels_smoke.rs @@ -0,0 +1,80 @@ +//! Smoke test for the channels target against a synthetic fixture. +//! The JS channels.mjs runs types through `openapi-typescript` which the +//! Rust codegen replaces with direct interface emission; byte-equivalence +//! against the JS baseline is intentionally not the gate. Instead this +//! test checks structural properties of the emitted output. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use mizan_codegen::config::{Config, SourceConfig}; +use mizan_codegen::emit::CodegenTarget; +use mizan_codegen::emit::channels::ChannelsTarget; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn fixture_config() -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec!["channels".to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: None, + rust_crate_name: None, + } +} + + +#[test] +fn channels_target_emits_expected_files() { + let raw = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/channels_schema.json"), + ).unwrap(); + let ir = parse_ir_from_str(&raw).unwrap(); + + let files = ChannelsTarget.emit(&ir, &fixture_config()); + assert_eq!(files.len(), 2, "channels target emits 2 files when channels present"); + + let by_path: BTreeMap = + files.iter().map(|f| (f.rel_path.clone(), f.content.as_str())).collect(); + + let ts = by_path.get(&PathBuf::from("channels.ts")) + .expect("channels.ts emitted"); + for expected in [ + "export interface ChatChannelParams", + "export interface ChatReactMessage", + "export interface ChatDjangoMessage", + "export interface NotificationsDjangoMessage", + "export const CHANNELS = {", + "chat: {", + "notifications: {", + "hasParams: true", + "hasParams: false", + ] { + assert!(ts.contains(expected), "channels.ts must contain {expected:?}"); + } + + let hooks = by_path.get(&PathBuf::from("channels.hooks.tsx")) + .expect("channels.hooks.tsx emitted"); + for expected in [ + "import { useChannel, type ChannelSubscription } from 'mizan/channels'", + "export function useChatChannel(params: ChatChannelParams)", + "export function useNotificationsChannel()", + "ChannelSubscription", + "ChannelSubscription, NotificationsDjangoMessage, never>", + ] { + assert!(hooks.contains(expected), "channels.hooks.tsx must contain {expected:?}"); + } +} + + +#[test] +fn channels_target_emits_nothing_when_empty() { + // AFI fixture has no channels — target should produce zero files. + let raw = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"), + ).unwrap(); + let ir = parse_ir_from_str(&raw).unwrap(); + let files = ChannelsTarget.emit(&ir, &fixture_config()); + assert!(files.is_empty(), "no channels → no files"); +} diff --git a/protocol/mizan-codegen/tests/fixtures/afi_schema.json b/protocol/mizan-codegen/tests/fixtures/afi_schema.json new file mode 100644 index 0000000..e7bdba7 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/afi_schema.json @@ -0,0 +1,685 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "mizan Server Functions", + "description": "Auto-generated schema for mizan server functions", + "version": "1.0.0" + }, + "paths": { + "/mizan/echo": { + "post": { + "summary": "Echoes the input back.", + "operationId": "echo", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/echoInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/echoOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/whoami": { + "post": { + "summary": "Returns the current user identity.", + "operationId": "whoami", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/whoamiOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/user_profile": { + "post": { + "summary": "One half of the user context.", + "operationId": "userProfile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userProfileInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userProfileOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": "user" + } + } + }, + "/mizan/user_orders": { + "post": { + "summary": "Other half of the user context \u2014 same param, proves param elevation.", + "operationId": "userOrders", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userOrdersInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/userOrdersOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": "user" + } + } + }, + "/mizan/update_profile": { + "post": { + "summary": "Mutation declaring affects on the user context.", + "operationId": "updateProfile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/updateProfileInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/updateProfileOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/find_user": { + "post": { + "summary": "Optional return \u2014 exercises Pydantic `T | None` schema introspection.", + "operationId": "findUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/findUserInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/findUserOutput" + }, + { + "type": "null" + } + ], + "title": "Response Finduser" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/rename_user": { + "post": { + "summary": "Merge target \u2014 kernel splices return value into the user context.", + "operationId": "renameUser", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/renameUserInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/renameUserOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "OrderOutput": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "user_id": { + "type": "integer", + "title": "User Id" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "id", + "user_id", + "total" + ], + "title": "OrderOutput" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "echoInput": { + "properties": { + "text": { + "type": "string", + "title": "Text" + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "echoInput" + }, + "echoOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "echoOutput" + }, + "findUserInput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + } + }, + "type": "object", + "required": [ + "user_id" + ], + "title": "findUserInput" + }, + "findUserOutput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "user_id", + "name" + ], + "title": "findUserOutput" + }, + "renameUserInput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "user_id", + "name" + ], + "title": "renameUserInput" + }, + "renameUserOutput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "user_id", + "name" + ], + "title": "renameUserOutput" + }, + "updateProfileInput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "user_id", + "name" + ], + "title": "updateProfileInput" + }, + "updateProfileOutput": { + "properties": { + "ok": { + "type": "boolean", + "title": "Ok" + } + }, + "type": "object", + "required": [ + "ok" + ], + "title": "updateProfileOutput" + }, + "userOrdersInput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + } + }, + "type": "object", + "required": [ + "user_id" + ], + "title": "userOrdersInput" + }, + "userOrdersOutput": { + "items": { + "$ref": "#/components/schemas/OrderOutput" + }, + "type": "array", + "title": "userOrdersOutput" + }, + "userProfileInput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + } + }, + "type": "object", + "required": [ + "user_id" + ], + "title": "userProfileInput" + }, + "userProfileOutput": { + "properties": { + "user_id": { + "type": "integer", + "title": "User Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "user_id", + "name" + ], + "title": "userProfileOutput" + }, + "whoamiOutput": { + "properties": { + "email": { + "type": "string", + "title": "Email" + }, + "authenticated": { + "type": "boolean", + "title": "Authenticated" + } + }, + "type": "object", + "required": [ + "email", + "authenticated" + ], + "title": "whoamiOutput" + } + } + }, + "x-mizan-functions": [ + { + "name": "echo", + "camelName": "echo", + "hasInput": true, + "inputType": "echoInput", + "outputType": "echoOutput", + "outputNullable": false, + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "whoami", + "camelName": "whoami", + "hasInput": false, + "inputType": null, + "outputType": "whoamiOutput", + "outputNullable": false, + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "user_profile", + "camelName": "userProfile", + "hasInput": true, + "inputType": "userProfileInput", + "outputType": "userProfileOutput", + "outputNullable": false, + "transport": "http", + "isContext": "user", + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "user_orders", + "camelName": "userOrders", + "hasInput": true, + "inputType": "userOrdersInput", + "outputType": "userOrdersOutput", + "outputNullable": false, + "transport": "http", + "isContext": "user", + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "update_profile", + "camelName": "updateProfile", + "hasInput": true, + "inputType": "updateProfileInput", + "outputType": "updateProfileOutput", + "outputNullable": false, + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null, + "affects": [ + { + "type": "context", + "name": "user" + } + ] + }, + { + "name": "find_user", + "camelName": "findUser", + "hasInput": true, + "inputType": "findUserInput", + "outputType": "findUserOutput", + "outputNullable": true, + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "rename_user", + "camelName": "renameUser", + "hasInput": true, + "inputType": "renameUserInput", + "outputType": "renameUserOutput", + "outputNullable": false, + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null, + "merge": [ + "user" + ] + } + ], + "x-mizan-contexts": { + "user": { + "functions": [ + "user_profile", + "user_orders" + ], + "params": { + "user_id": { + "type": "integer", + "sharedBy": [ + "user_profile", + "user_orders" + ], + "required": true + } + } + } + } +} \ No newline at end of file diff --git a/protocol/mizan-codegen/tests/fixtures/channels_schema.json b/protocol/mizan-codegen/tests/fixtures/channels_schema.json new file mode 100644 index 0000000..618365e --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/channels_schema.json @@ -0,0 +1,55 @@ +{ + "x-mizan-channels": [ + { + "name": "chat", + "pascalName": "Chat", + "hasParams": true, + "hasReactMessage": true, + "hasDjangoMessage": true, + "paramsType": "ChatChannelParams", + "reactMessageType": "ChatReactMessage", + "djangoMessageType": "ChatDjangoMessage" + }, + { + "name": "notifications", + "pascalName": "Notifications", + "hasParams": false, + "hasReactMessage": false, + "hasDjangoMessage": true, + "djangoMessageType": "NotificationsDjangoMessage" + } + ], + "components": { + "schemas": { + "ChatChannelParams": { + "type": "object", + "properties": { + "room_id": { "type": "string" } + }, + "required": ["room_id"] + }, + "ChatReactMessage": { + "type": "object", + "properties": { + "text": { "type": "string" } + }, + "required": ["text"] + }, + "ChatDjangoMessage": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "from_user": { "type": "string" } + }, + "required": ["text", "from_user"] + }, + "NotificationsDjangoMessage": { + "type": "object", + "properties": { + "body": { "type": "string" } + }, + "required": ["body"] + } + } + } +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/__init__.py b/protocol/mizan-codegen/tests/fixtures/js_python/__init__.py new file mode 100644 index 0000000..6c39b89 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_python/__init__.py @@ -0,0 +1,4 @@ +# AUTO-GENERATED by mizan — do not edit + +from .client import MizanClient # noqa: F401 +from .types import * # noqa: F401, F403 diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/client.py b/protocol/mizan-codegen/tests/fixtures/js_python/client.py new file mode 100644 index 0000000..45d29a8 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_python/client.py @@ -0,0 +1,67 @@ +# AUTO-GENERATED by mizan — do not edit + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +# Built from frontends/mizan-rust with `maturin develop --features pyo3`. +from mizan_rust import PyMizanClient, PyContextSubscription + +from .types import * # noqa: F401, F403 +from .types import BaseModel # re-import for the synthesized ContextData classes + + +class MizanClient: + """Typed Python facade over the PyO3 mizan-rust kernel.""" + + def __init__(self, base_url: str, *, session: bool = False, + csrf_cookie_name: str = "csrftoken", + csrf_header_name: str = "X-CSRFToken") -> None: + self._inner = PyMizanClient( + base_url, + session=session, + csrf_cookie_name=csrf_cookie_name, + csrf_header_name=csrf_header_name, + ) + + def fetch_user_context(self, user_id: int) -> "UserContextData": + raw = self._inner.fetch_context("user", {"user_id": user_id}) + return UserContextData(**raw) + def subscribe_user_context(self, user_id: int, + callback: Callable[[dict[str, Any]], None]) -> PyContextSubscription: + return self._inner.subscribe_context("user", {"user_id": user_id}, callback) + + def call_echo(self, args: EchoInput) -> EchoOutput: + 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 + + def call_rename_user(self, args: RenameUserInput) -> RenameUserOutput: + raw = self._inner.call("rename_user", args.model_dump()) + return RenameUserOutput(**raw) + + def invalidate(self, context: str) -> None: + self._inner.invalidate(context) + + def invalidate_scoped(self, context: str, params: dict[str, Any]) -> None: + self._inner.invalidate_scoped(context, params) + + +# ── Context data shapes (per-context bundle) ────────────────────────────── + +class UserContextData(BaseModel): + """Bundled return of fetch_user_context.""" + user_profile: UserProfileOutput + user_orders: UserOrdersOutput diff --git a/protocol/mizan-codegen/tests/fixtures/js_python/types.py b/protocol/mizan-codegen/tests/fixtures/js_python/types.py new file mode 100644 index 0000000..996be73 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_python/types.py @@ -0,0 +1,66 @@ +# AUTO-GENERATED by mizan — do not edit + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel + +class HTTPValidationError(BaseModel): + detail: list[ValidationError] | None = None + +class OrderOutput(BaseModel): + id: int + user_id: int + total: int + +class ValidationError(BaseModel): + loc: list[Any] + msg: str + r#type: str + input: Any | None = None + ctx: dict[str, Any] | None = None + +class EchoInput(BaseModel): + text: str + +class EchoOutput(BaseModel): + message: str + +class FindUserInput(BaseModel): + user_id: int + +class FindUserOutput(BaseModel): + user_id: int + name: str + +class RenameUserInput(BaseModel): + user_id: int + name: str + +class RenameUserOutput(BaseModel): + user_id: int + name: str + +class UpdateProfileInput(BaseModel): + user_id: int + name: str + +class UpdateProfileOutput(BaseModel): + ok: bool + +class UserOrdersInput(BaseModel): + user_id: int + +UserOrdersOutput = list[OrderOutput] + +class UserProfileInput(BaseModel): + user_id: int + +class UserProfileOutput(BaseModel): + user_id: int + name: str + +class WhoamiOutput(BaseModel): + email: str + authenticated: bool diff --git a/protocol/mizan-codegen/tests/fixtures/js_react/react.tsx b/protocol/mizan-codegen/tests/fixtures/js_react/react.tsx new file mode 100644 index 0000000..6fc7c64 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_react/react.tsx @@ -0,0 +1,157 @@ +'use client' + +// AUTO-GENERATED by mizan — do not edit + +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react' +import { + configure, + initSession, + mizanCall, + mizanFetch, + MizanError, + registerContext, + type ContextState, +} from '@mizan/base' + +import { fetchUserContext, type UserContextData, type UserContextParams, callUpdateProfile, callEcho, callWhoami, callFindUser, callRenameUser, type userProfileOutput, type userOrdersOutput } from './index' + +// Internal — runs inside a Provider, registers with the kernel exactly once. +function useContextSubscription( + name: string, + params: Record, + fetchFn: () => Promise, + initialData?: T, +): ContextState { + const ref = useRef | null>(null) + if (!ref.current) { + ref.current = registerContext(name, params, fetchFn, initialData) + } + const handle = ref.current + + useEffect(() => { + if (handle.getState().status === 'idle') handle.refetch() + return () => handle.unregister() + }, [handle]) + + return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState) +} + +// Internal — wraps an imperative call() with isPending / error state. +interface MutationHook { + mutate: (args: TArgs) => Promise + isPending: boolean + error: Error | null +} + +function useMutation( + callFn: (args: TArgs) => Promise, +): MutationHook { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const mutate = useCallback(async (args: TArgs) => { + setIsPending(true) + setError(null) + try { + return await callFn(args) + } catch (e) { + setError(e as Error) + throw e + } finally { + setIsPending(false) + } + }, [callFn]) + + return { mutate, isPending, error } +} + +// ── User Context ── + +const UserCtx = createContext | null>(null) + +export function UserContext({ children, ...params }: UserContextParams & { children: ReactNode }) { + const state = useContextSubscription('user', params, () => fetchUserContext(params)) + return {children} +} + +export function useUserContext(): ContextState { + const ctx = useContext(UserCtx) + if (!ctx) throw new Error('useUserContext requires ') + 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 useUpdateProfile() { + return useMutation[0], Awaited>>(callUpdateProfile) +} + +export function useEcho() { + return useMutation[0], Awaited>>(callEcho) +} + +export function useWhoami() { + return useMutation>>(() => callWhoami() as any) +} + +export function useFindUser() { + return useMutation[0], Awaited>>(callFindUser) +} + +export function useRenameUser() { + return useMutation[0], Awaited>>(callRenameUser) +} + +// ── MizanContext root provider ── + +export interface MizanContextProps { + /** Base URL for protocol endpoints. Defaults to "/api/mizan". */ + baseUrl?: string + /** Set to `false` for backends without a `/session/` endpoint (e.g. FastAPI). */ + session?: boolean + children: ReactNode +} + +/** + * Root provider — calls configure() once and mounts the global context (if defined). + * Must wrap any component using Mizan-generated hooks. + */ +export function MizanContext({ baseUrl, session, children }: MizanContextProps) { + const configured = useRef(false) + if (!configured.current) { + const opts: Parameters[0] = {} + if (baseUrl !== undefined) opts.baseUrl = baseUrl + if (session !== undefined) opts.session = session + if (Object.keys(opts).length > 0) configure(opts) + configured.current = true + } + return <>{children} +} + +// ── Imperative escape hatch ── + +/** + * Returns the imperative kernel API. For test harnesses or rare cases where + * a typed generated hook does not fit. Most app code should use the typed hooks. + */ +export function useMizan() { + return { call: mizanCall, fetch: mizanFetch } +} + +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/Cargo.toml b/protocol/mizan-codegen/tests/fixtures/js_rust/Cargo.toml new file mode 100644 index 0000000..0b6de81 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "fixture_client" +version = "0.1.0" +edition = "2021" + +[dependencies] +mizan-rust = { path = "../../../frontends/mizan-rust" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/mod.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/mod.rs new file mode 100644 index 0000000..10d1453 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/mod.rs @@ -0,0 +1,3 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod user; diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/user.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/user.rs new file mode 100644 index 0000000..c9d1cdb --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/contexts/user.rs @@ -0,0 +1,29 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{UserProfileOutput, UserOrdersOutput}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserContextData { + pub user_profile: UserProfileOutput, + pub user_orders: UserOrdersOutput, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserContextParams { + pub user_id: i64, +} + +pub async fn fetch_user_context( + client: &MizanClient, + params: &UserContextParams, +) -> Result { + let params_value = serde_json::to_value(params).unwrap_or(Value::Object(Default::default())); + let raw = client.fetch_context("user", ¶ms_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode user context: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/echo.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/echo.rs new file mode 100644 index 0000000..86ef70e --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/echo.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{EchoOutput, EchoInput}; + +pub async fn call_echo(client: &MizanClient, args: &EchoInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("echo", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode echo result: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/find_user.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/find_user.rs new file mode 100644 index 0000000..3d7f9f0 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/find_user.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{FindUserOutput, FindUserInput}; + +pub async fn call_find_user(client: &MizanClient, args: &FindUserInput) -> Result, MizanError> { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("find_user", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode find_user result: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/mod.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/mod.rs new file mode 100644 index 0000000..e277fc5 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/mod.rs @@ -0,0 +1,6 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod echo; +pub mod find_user; +pub mod rename_user; +pub mod whoami; diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/rename_user.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/rename_user.rs new file mode 100644 index 0000000..cc9820b --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/rename_user.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{RenameUserOutput, RenameUserInput}; + +pub async fn call_rename_user(client: &MizanClient, args: &RenameUserInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("rename_user", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode rename_user result: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/whoami.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/whoami.rs new file mode 100644 index 0000000..4512523 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/functions/whoami.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{WhoamiOutput}; + +pub async fn call_whoami(client: &MizanClient) -> Result { + let args_value = Value::Object(Default::default()); + let raw = client.call("whoami", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode whoami result: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/lib.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/lib.rs new file mode 100644 index 0000000..cfc3e80 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/lib.rs @@ -0,0 +1,8 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod types; +pub mod contexts; +pub mod mutations; +pub mod functions; + +pub use mizan_rust::{MizanClient, MizanConfig, MizanError}; diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/mod.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/mod.rs new file mode 100644 index 0000000..4410f89 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/mod.rs @@ -0,0 +1,3 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod update_profile; diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/update_profile.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/update_profile.rs new file mode 100644 index 0000000..819af92 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/mutations/update_profile.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{UpdateProfileOutput, UpdateProfileInput}; + +pub async fn call_update_profile(client: &MizanClient, args: &UpdateProfileInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("update_profile", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode update_profile result: {e}"))) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs b/protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs new file mode 100644 index 0000000..7067fdb --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_rust/src/types.rs @@ -0,0 +1,98 @@ +// AUTO-GENERATED by mizan — do not edit + +#![allow(non_camel_case_types)] + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPValidationError { + pub detail: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderOutput { + pub id: i64, + pub user_id: i64, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationError { + pub loc: Vec, + pub msg: String, + #[serde(rename = "type")] + pub r#type: String, + pub input: Option, + pub ctx: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoInput { + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoOutput { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindUserInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindUserOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameUserInput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameUserOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProfileInput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProfileOutput { + pub ok: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserOrdersInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct UserOrdersOutput(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfileInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfileOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoamiOutput { + pub email: String, + pub authenticated: bool, +} + diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/contexts/user.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/contexts/user.ts new file mode 100644 index 0000000..24ababc --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/contexts/user.ts @@ -0,0 +1,18 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanFetch } from '@mizan/base' + +import type { userProfileOutput, userOrdersOutput } from '../types' + +export interface UserContextData { + user_profile: userProfileOutput + user_orders: userOrdersOutput +} + +export interface UserContextParams { + user_id: number +} + +export function fetchUserContext(params: UserContextParams): Promise { + return mizanFetch('user', params) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/echo.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/echo.ts new file mode 100644 index 0000000..5374efe --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/echo.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { echoInput, echoOutput } from '../types' + +export function callEcho(args: echoInput): Promise { + return mizanCall('echo', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/findUser.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/findUser.ts new file mode 100644 index 0000000..a04ecab --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/findUser.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { findUserInput, findUserOutput } from '../types' + +export function callFindUser(args: findUserInput): Promise { + return mizanCall('find_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/renameUser.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/renameUser.ts new file mode 100644 index 0000000..3117ea6 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/renameUser.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { renameUserInput, renameUserOutput } from '../types' + +export function callRenameUser(args: renameUserInput): Promise { + return mizanCall('rename_user', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/whoami.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/whoami.ts new file mode 100644 index 0000000..9e2e136 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/functions/whoami.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { whoamiOutput } from '../types' + +export function callWhoami(): Promise { + return mizanCall('whoami', {}) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/index.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/index.ts new file mode 100644 index 0000000..25059d6 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/index.ts @@ -0,0 +1,11 @@ +// AUTO-GENERATED by mizan — do not edit + +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' diff --git a/protocol/mizan-codegen/tests/fixtures/js_stage1/mutations/updateProfile.ts b/protocol/mizan-codegen/tests/fixtures/js_stage1/mutations/updateProfile.ts new file mode 100644 index 0000000..0ed94d9 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_stage1/mutations/updateProfile.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { updateProfileInput, updateProfileOutput } from '../types' + +export function callUpdateProfile(args: updateProfileInput): Promise { + return mizanCall('update_profile', args) +} diff --git a/protocol/mizan-codegen/tests/fixtures/js_svelte/svelte.ts b/protocol/mizan-codegen/tests/fixtures/js_svelte/svelte.ts new file mode 100644 index 0000000..f0b667d --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_svelte/svelte.ts @@ -0,0 +1,29 @@ +// AUTO-GENERATED by mizan — do not edit + +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' + +export function createUserContext(params: UserContextParams) { + const store = readable>( + { data: null, status: 'idle', error: null }, + (set) => { + const handle = registerContext('user', params, () => fetchUserContext(params)) + const unsub = handle.subscribe(() => set(handle.getState())) + handle.refetch() + return () => { unsub(); handle.unregister() } + }, + ) + + return store +} + +export { callUpdateProfile } from '../index' +export { callEcho } from '../index' +export { callWhoami } from '../index' +export { callFindUser } from '../index' +export { callRenameUser } from '../index' + +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' diff --git a/protocol/mizan-codegen/tests/fixtures/js_vue/vue.ts b/protocol/mizan-codegen/tests/fixtures/js_vue/vue.ts new file mode 100644 index 0000000..f9fe057 --- /dev/null +++ b/protocol/mizan-codegen/tests/fixtures/js_vue/vue.ts @@ -0,0 +1,96 @@ +// AUTO-GENERATED by mizan — do not edit + +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' + +export function useUserContext(params: UserContextParams) { + const state = ref>({ data: null, status: 'idle', error: null }) + let handle: ReturnType | null = null + + onMounted(() => { + handle = registerContext('user', params, () => fetchUserContext(params)) + handle.subscribe(() => { state.value = handle!.getState() }) + handle.refetch() + }) + + onServerPrefetch(async () => { + handle = registerContext('user', params, () => fetchUserContext(params)) + await handle.refetch() + state.value = handle.getState() + }) + + onUnmounted(() => { handle?.unregister() }) + + return { + state, + userProfile: computed(() => state.value.data?.user_profile ?? null) as ComputedRef, + userOrders: computed(() => state.value.data?.user_orders ?? null) as ComputedRef, + loading: computed(() => state.value.status === 'loading'), + error: computed(() => state.value.error), + } +} + +export function useUpdateProfile() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callUpdateProfile(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + +export function useEcho() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callEcho(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + +export function useWhoami() { + const isPending = ref(false) + const error = ref(null) + async function mutate() { + isPending.value = true; error.value = null + try { return await callWhoami() } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + +export function useFindUser() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callFindUser(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + +export function useRenameUser() { + const isPending = ref(false) + const error = ref(null) + async function mutate(args: Parameters[0]) { + isPending.value = true; error.value = null + try { return await callRenameUser(args) } + catch (e) { error.value = e as Error; throw e } + finally { isPending.value = false } + } + return { mutate, isPending, error } +} + +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' diff --git a/protocol/mizan-codegen/tests/ir_deserialization.rs b/protocol/mizan-codegen/tests/ir_deserialization.rs new file mode 100644 index 0000000..d8dfc84 --- /dev/null +++ b/protocol/mizan-codegen/tests/ir_deserialization.rs @@ -0,0 +1,103 @@ +//! IR deserialization tests against the AFI fixture schema. +//! +//! The fixture is captured from the FastAPI backend's `build_schema()` +//! against `tests/afi/fixture.py`. Each test exercises a different facet +//! of the IR — function set, per-function field decoding, context-param +//! elevation, and components.schemas presence — to confirm the typed +//! Rust structs match the JSON shape the backends emit. + +use std::path::PathBuf; + +use mizan_codegen::fetch::parse_ir_from_str; +use mizan_codegen::ir::{AffectKind, IsContext, Transport}; + + +fn load_fixture() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/afi_schema.json"); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + parse_ir_from_str(&raw).unwrap_or_else(|e| panic!("parse IR: {e}")) +} + + +#[test] +fn afi_fixture_deserializes_function_set() { + let ir = load_fixture(); + let names: Vec<&str> = ir.functions.iter().map(|f| f.name.as_str()).collect(); + + // Seven fixture functions per tests/afi/fixture.py. + assert_eq!(ir.functions.len(), 7, "expected 7 functions, got {}: {names:?}", ir.functions.len()); + + for expected in [ + "echo", "whoami", + "user_profile", "user_orders", + "update_profile", "find_user", "rename_user", + ] { + assert!(names.contains(&expected), "missing function {expected:?} in {names:?}"); + } +} + + +#[test] +fn afi_fixture_function_field_decode() { + let ir = load_fixture(); + let echo = ir.functions.iter().find(|f| f.name == "echo").unwrap(); + assert_eq!(echo.camel_name, "echo"); + assert!(echo.has_input); + assert_eq!(echo.input_type.as_deref(), Some("echoInput")); + assert_eq!(echo.output_type, "echoOutput"); + assert!(!echo.output_nullable); + assert_eq!(echo.transport, Transport::Http); + assert_eq!(echo.is_context, IsContext::No); + + let whoami = ir.functions.iter().find(|f| f.name == "whoami").unwrap(); + assert!(!whoami.has_input); + + // `find_user` returns `ProfileOutput | None` — outputNullable must be true. + let find_user = ir.functions.iter().find(|f| f.name == "find_user").unwrap(); + assert!(find_user.output_nullable, "find_user must be outputNullable"); + + // Context-typed function picks up the context name. + let user_profile = ir.functions.iter().find(|f| f.name == "user_profile").unwrap(); + assert_eq!(user_profile.is_context.as_str(), Some("user")); + + // Mutation with `affects="user"` lands in `affects` as a context target. + let update_profile = ir.functions.iter().find(|f| f.name == "update_profile").unwrap(); + assert_eq!(update_profile.affects.len(), 1); + assert_eq!(update_profile.affects[0].kind, AffectKind::Context); + assert_eq!(update_profile.affects[0].name, "user"); +} + + +#[test] +fn afi_fixture_context_param_elevation() { + let ir = load_fixture(); + let user = ir.contexts.get("user").expect("user context group"); + + // Both context functions share `user_id` as a required param. + let user_id = user.params.get("user_id").expect("user_id param"); + assert_eq!(user_id.ty, "integer"); + assert!(user_id.required, "user_id is required (declared by every fn in the group)"); + assert!(user_id.shared_by.contains(&"user_profile".to_string())); + assert!(user_id.shared_by.contains(&"user_orders".to_string())); +} + + +#[test] +fn afi_fixture_components_schemas_present() { + let ir = load_fixture(); + // Each fixture function pairs with an *Input/Output schema in components. + for expected in [ + "echoInput", "echoOutput", + "whoamiOutput", + "userProfileInput", "userProfileOutput", + "updateProfileInput", "updateProfileOutput", + "findUserInput", "findUserOutput", + ] { + assert!( + ir.components.schemas.contains_key(expected), + "missing schema {expected:?}", + ); + } +} diff --git a/protocol/mizan-codegen/tests/python_parity.rs b/protocol/mizan-codegen/tests/python_parity.rs new file mode 100644 index 0000000..68261a9 --- /dev/null +++ b/protocol/mizan-codegen/tests/python_parity.rs @@ -0,0 +1,75 @@ +//! Byte-equivalence test for the Python target against the JS baseline. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use mizan_codegen::config::{Config, SourceConfig}; +use mizan_codegen::emit::{CodegenTarget, EmittedFile}; +use mizan_codegen::emit::python::PythonClient; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn load_ir() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"); + parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + + +fn fixture_config() -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec!["python".to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: None, + rust_crate_name: None, + } +} + + +fn read_baseline(rel: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/js_python") + .join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display())) +} + + +fn emit_index(files: &[EmittedFile]) -> BTreeMap { + files.iter().map(|f| (f.rel_path.clone(), f.content.as_str())).collect() +} + + +fn assert_byte_equal(rel: &str, files: &BTreeMap) { + let actual = files + .get(&PathBuf::from(rel)) + .unwrap_or_else(|| panic!("Python target did not produce {rel}")); + let expected = read_baseline(rel); + if *actual != expected { + for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + if a != b { + panic!( + "{rel} diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "{rel} diverges in length: actual={} expected={}", + actual.len(), expected.len(), + ); + } +} + + +#[test] +fn python_target_all_files_match_baseline() { + let ir = load_ir(); + let files = PythonClient.emit(&ir, &fixture_config()); + let index = emit_index(&files); + + for rel in ["types.py", "client.py", "__init__.py"] { + assert_byte_equal(rel, &index); + } +} diff --git a/protocol/mizan-codegen/tests/react_parity.rs b/protocol/mizan-codegen/tests/react_parity.rs new file mode 100644 index 0000000..464698d --- /dev/null +++ b/protocol/mizan-codegen/tests/react_parity.rs @@ -0,0 +1,54 @@ +//! Byte-equivalence test for the React target against the JS baseline. + +use std::path::PathBuf; + +use mizan_codegen::config::{Config, SourceConfig}; +use mizan_codegen::emit::CodegenTarget; +use mizan_codegen::emit::react::ReactAdapter; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn load_ir() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"); + parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + + +fn fixture_config() -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec!["react".to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: None, + rust_crate_name: None, + } +} + + +#[test] +fn react_target_byte_match() { + let ir = load_ir(); + let files = ReactAdapter.emit(&ir, &fixture_config()); + assert_eq!(files.len(), 1); + let actual = &files[0].content; + + let expected_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/js_react/react.tsx"); + let expected = std::fs::read_to_string(&expected_path).unwrap(); + + if *actual != expected { + for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + if a != b { + panic!( + "react.tsx diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "react.tsx diverges in length: actual={} expected={}", + actual.len(), expected.len(), + ); + } +} diff --git a/protocol/mizan-codegen/tests/rust_parity.rs b/protocol/mizan-codegen/tests/rust_parity.rs new file mode 100644 index 0000000..9a25f30 --- /dev/null +++ b/protocol/mizan-codegen/tests/rust_parity.rs @@ -0,0 +1,96 @@ +//! Byte-equivalence between the Rust target and the JS rust.mjs baseline +//! against the AFI fixture. The downstream forcing function is the wire- +//! parity drivers under `tests/rust/`; this test catches divergence +//! earlier in the cycle without needing to spin a FastAPI fixture up. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use mizan_codegen::config::{Config, RustKernelSpec, SourceConfig}; +use mizan_codegen::emit::{CodegenTarget, EmittedFile}; +use mizan_codegen::emit::rust::RustCrate; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn load_ir() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/afi_schema.json"); + parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + + +fn fixture_config() -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec!["rust".to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: Some(RustKernelSpec::Path { + path: "../../../frontends/mizan-rust".to_string(), + }), + rust_crate_name: Some("fixture_client".to_string()), + } +} + + +fn read_baseline(rel: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/js_rust") + .join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display())) +} + + +fn emit_index(files: &[EmittedFile]) -> BTreeMap { + files.iter().map(|f| (f.rel_path.clone(), f.content.as_str())).collect() +} + + +fn assert_byte_equal(rel: &str, files: &BTreeMap) { + let actual = files + .get(&PathBuf::from(rel)) + .unwrap_or_else(|| panic!("Rust target did not produce {rel}")); + let expected = read_baseline(rel); + if *actual != expected { + for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + if a != b { + panic!( + "{rel} diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "{rel} diverges in length: actual={} expected={}\n--- actual (last 200) ---\n{}\n--- expected (last 200) ---\n{}", + actual.len(), expected.len(), + &actual[actual.len().saturating_sub(200)..], + &expected[expected.len().saturating_sub(200)..], + ); + } +} + + +#[test] +fn rust_target_all_files_match_baseline() { + let ir = load_ir(); + let files = RustCrate.emit(&ir, &fixture_config()); + let index = emit_index(&files); + + for rel in [ + "Cargo.toml", + "src/lib.rs", + "src/types.rs", + "src/contexts/user.rs", + "src/contexts/mod.rs", + "src/functions/echo.rs", + "src/functions/whoami.rs", + "src/functions/find_user.rs", + "src/functions/rename_user.rs", + "src/functions/mod.rs", + "src/mutations/update_profile.rs", + "src/mutations/mod.rs", + ] { + assert_byte_equal(rel, &index); + } +} diff --git a/protocol/mizan-codegen/tests/stage1_parity.rs b/protocol/mizan-codegen/tests/stage1_parity.rs new file mode 100644 index 0000000..5861dc8 --- /dev/null +++ b/protocol/mizan-codegen/tests/stage1_parity.rs @@ -0,0 +1,143 @@ +//! Byte-equivalence tests for the deterministic Stage 1 files (contexts, +//! mutations, functions, index). Baseline output captured from the JS +//! codegen at `protocol/mizan-generate/generator/lib/stage1.mjs` against +//! the AFI fixture schema (`tests/fixtures/afi_schema.json`). +//! +//! `types.ts` is NOT byte-checked here — the JS codegen routes type +//! emission through openapi-typescript while the Rust substrate emits +//! Pydantic schemas directly. Equivalence for types.ts is structural +//! (named exports present and importable), checked in a separate test. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use mizan_codegen::config::{Config, SourceConfig}; +use mizan_codegen::emit::{CodegenTarget, EmittedFile}; +use mizan_codegen::emit::stage1::Stage1; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn load_ir() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/afi_schema.json"); + let raw = std::fs::read_to_string(&path).unwrap(); + parse_ir_from_str(&raw).unwrap() +} + + +fn synthetic_config() -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec!["stage1".to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: None, + rust_crate_name: None, + } +} + + +fn emit_index(files: &[EmittedFile]) -> BTreeMap { + files.iter().map(|f| (f.rel_path.clone(), f.content.as_str())).collect() +} + + +fn read_baseline(rel: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/js_stage1") + .join(rel); + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read baseline {}: {e}", path.display())) +} + + +fn assert_byte_equal(rel: &str, files: &BTreeMap) { + let actual = files + .get(&PathBuf::from(rel)) + .unwrap_or_else(|| panic!("emitter did not produce {rel}")); + let expected = read_baseline(rel); + if *actual != expected { + // Surface a diff-friendly failure message — first divergent line wins. + for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() { + if a != b { + panic!( + "{rel} diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "{rel} diverges in length: actual={} expected={}", + actual.len(), expected.len(), + ); + } +} + + +#[test] +fn stage1_contexts_user_byte_match() { + let ir = load_ir(); + let files = Stage1.emit(&ir, &synthetic_config()); + let index = emit_index(&files); + assert_byte_equal("contexts/user.ts", &index); +} + + +#[test] +fn stage1_function_files_byte_match() { + let ir = load_ir(); + let files = Stage1.emit(&ir, &synthetic_config()); + let index = emit_index(&files); + for rel in ["functions/echo.ts", "functions/whoami.ts", + "functions/findUser.ts", "functions/renameUser.ts"] { + assert_byte_equal(rel, &index); + } +} + + +#[test] +fn stage1_mutation_files_byte_match() { + let ir = load_ir(); + let files = Stage1.emit(&ir, &synthetic_config()); + let index = emit_index(&files); + assert_byte_equal("mutations/updateProfile.ts", &index); +} + + +#[test] +fn stage1_index_byte_match() { + let ir = load_ir(); + let files = Stage1.emit(&ir, &synthetic_config()); + let index = emit_index(&files); + assert_byte_equal("index.ts", &index); +} + + +#[test] +fn stage1_types_exports_expected_names() { + let ir = load_ir(); + let files = Stage1.emit(&ir, &synthetic_config()); + let index = emit_index(&files); + let types = index + .get(&PathBuf::from("types.ts")) + .expect("types.ts must be emitted"); + + // Every Pydantic schema named in the IR must surface as a top-level + // exported type or interface so Stage 2 adapters can import by name. + for expected in [ + "echoInput", "echoOutput", + "whoamiOutput", + "userProfileInput", "userProfileOutput", + "userOrdersInput", + "updateProfileInput", "updateProfileOutput", + "findUserInput", "findUserOutput", + "renameUserInput", "renameUserOutput", + ] { + let needle_interface = format!("export interface {expected} "); + let needle_type = format!("export type {expected} ="); + assert!( + types.contains(&needle_interface) || types.contains(&needle_type), + "types.ts must export {expected:?} (interface or type)", + ); + } +} diff --git a/protocol/mizan-codegen/tests/vue_svelte_parity.rs b/protocol/mizan-codegen/tests/vue_svelte_parity.rs new file mode 100644 index 0000000..c60d328 --- /dev/null +++ b/protocol/mizan-codegen/tests/vue_svelte_parity.rs @@ -0,0 +1,66 @@ +//! Byte-equivalence tests for Vue + Svelte targets against JS baselines. + +use std::path::PathBuf; + +use mizan_codegen::config::{Config, SourceConfig}; +use mizan_codegen::emit::CodegenTarget; +use mizan_codegen::emit::svelte::SvelteAdapter; +use mizan_codegen::emit::vue::VueAdapter; +use mizan_codegen::fetch::parse_ir_from_str; + + +fn load_ir() -> mizan_codegen::ir::MizanIR { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/afi_schema.json"); + parse_ir_from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + + +fn fixture_config(target: &str) -> Config { + Config { + project_id: None, + output: PathBuf::from("/tmp"), + targets: vec![target.to_string()], + source: SourceConfig { fastapi: None, django: None }, + rust_kernel: None, + rust_crate_name: None, + } +} + + +fn assert_byte_equal(actual: &str, baseline_path: &str, label: &str) { + let baseline = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(baseline_path), + ).unwrap(); + if actual != baseline { + for (lineno, (a, b)) in actual.lines().zip(baseline.lines()).enumerate() { + if a != b { + panic!( + "{label} diverges at line {}:\n expected: {b:?}\n actual: {a:?}", + lineno + 1, + ); + } + } + panic!( + "{label} diverges in length: actual={} expected={}", + actual.len(), baseline.len(), + ); + } +} + + +#[test] +fn vue_target_byte_match() { + let ir = load_ir(); + let files = VueAdapter.emit(&ir, &fixture_config("vue")); + assert_eq!(files.len(), 1); + assert_byte_equal(&files[0].content, "tests/fixtures/js_vue/vue.ts", "vue.ts"); +} + + +#[test] +fn svelte_target_byte_match() { + let ir = load_ir(); + let files = SvelteAdapter.emit(&ir, &fixture_config("svelte")); + assert_eq!(files.len(), 1); + assert_byte_equal(&files[0].content, "tests/fixtures/js_svelte/svelte.ts", "svelte.ts"); +} diff --git a/protocol/mizan-generate/bin/launcher.mjs b/protocol/mizan-generate/bin/launcher.mjs new file mode 100755 index 0000000..047cc71 --- /dev/null +++ b/protocol/mizan-generate/bin/launcher.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Mizan codegen npm-package shim — dispatches to the platform-appropriate +// `mizan-generate` Rust binary in this directory. Source for the binary +// lives at `protocol/mizan-codegen/`; published releases ship one binary +// per supported platform. + +import { spawn } from 'child_process' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { existsSync } from 'fs' +import { platform, arch } from 'os' + +const here = dirname(fileURLToPath(import.meta.url)) + +const platforms = { + 'linux-x64': 'mizan-generate-linux-x64', + 'darwin-arm64': 'mizan-generate-darwin-arm64', + 'darwin-x64': 'mizan-generate-darwin-x64', + 'win32-x64': 'mizan-generate-win32-x64.exe', +} + +const key = `${platform()}-${arch()}` +const binName = platforms[key] +if (!binName) { + console.error(`[mizan-generate] no prebuilt binary for ${key}`) + process.exit(1) +} + +const binPath = join(here, binName) +if (!existsSync(binPath)) { + console.error(`[mizan-generate] binary missing: ${binPath}`) + console.error('[mizan-generate] build from source: cargo build --release --manifest-path /protocol/mizan-codegen/Cargo.toml') + process.exit(1) +} + +const child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit' }) +child.on('exit', code => process.exit(code ?? 1)) +child.on('error', err => { + console.error(`[mizan-generate] failed to spawn ${binPath}: ${err.message}`) + process.exit(1) +}) diff --git a/protocol/mizan-generate/bin/mizan-generate-linux-x64 b/protocol/mizan-generate/bin/mizan-generate-linux-x64 new file mode 100755 index 0000000..9478707 Binary files /dev/null and b/protocol/mizan-generate/bin/mizan-generate-linux-x64 differ diff --git a/protocol/mizan-generate/generator/cli.mjs b/protocol/mizan-generate/generator/cli.mjs deleted file mode 100755 index d9158ae..0000000 --- a/protocol/mizan-generate/generator/cli.mjs +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -/** - * mizan Code Generator CLI - * - * Two-stage codegen: - * Stage 1: Framework-agnostic types + fetch/mutation functions - * Stage 2: Framework-specific wrappers (React hooks, Vue composables, Svelte stores) - * - * Usage: - * npx mizan-generate # React (default) - * npx mizan-generate --target vue # Vue - * npx mizan-generate --target react,vue,svelte # All three - */ - -import { promises as fs } from 'fs' -import path from 'path' -import { fetchChannelsSchema, fetchMizanSchema } from './lib/fetch.mjs' -import { generateTypes, generateContextFile, generateMutationFile, generateFunctionFile, generateStage1Index } from './lib/stage1.mjs' -import { generateReactAdapter } from './lib/adapters/react.mjs' -import { generateVueAdapter } from './lib/adapters/vue.mjs' -import { generateSvelteAdapter } from './lib/adapters/svelte.mjs' -import { generateChannelsFiles } from './lib/channels.mjs' - -const frontendDir = process.cwd() - -async function loadConfig(configPath) { - const fullPath = path.resolve(frontendDir, configPath) - try { await fs.access(fullPath) } catch { throw new Error(`Config not found: ${fullPath}`) } - const fileUrl = new URL(`file://${fullPath.replace(/\\/g, '/')}`) - const module = await import(fileUrl) - return module.default -} - -async function writeOutput(filePath, content) { - const dir = path.dirname(filePath) - await fs.mkdir(dir, { recursive: true }) - await fs.writeFile(filePath, content, 'utf8') -} - -function pascalCase(str) { - return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') -} - -async function generate(config, options = {}) { - const { output, target: targetFlag } = options - const outputDir = output || config.output || 'src/api' - const targets = (targetFlag || config.target || 'react').split(',').map(t => t.trim()) - - console.log(`[mizan] Starting generation (targets: ${targets.join(', ')})...`) - - const fullOutputDir = path.resolve(frontendDir, outputDir) - let mizanSchema = null - let channelsSchema = null - - // ── Channels (React-only for now) ─────────────────────────────────── - - try { - console.log('[mizan] Fetching channels schema...') - channelsSchema = await fetchChannelsSchema(config.source, frontendDir) - const channelCount = channelsSchema['x-mizan-channels']?.length || 0 - if (channelCount > 0 && targets.includes('react')) { - console.log(`[mizan] Found ${channelCount} channels`) - const { types, hooks } = await generateChannelsFiles(channelsSchema) - await writeOutput(path.join(fullOutputDir, 'channels.ts'), types) - if (hooks) await writeOutput(path.join(fullOutputDir, 'channels.hooks.tsx'), hooks) - } - } catch (err) { - console.log(`[mizan] Channels not available: ${err.message}`) - } - - // ── Mizan functions ───────────────────────────────────────────────── - - try { - console.log('[mizan] Fetching mizan schema...') - mizanSchema = await fetchMizanSchema(config.source, frontendDir) - - const functions = mizanSchema['x-mizan-functions'] || [] - const contextGroups = mizanSchema['x-mizan-contexts'] || {} - - if (functions.length === 0) { - console.log('[mizan] No functions registered') - return - } - - console.log(`[mizan] Found ${functions.length} functions`) - - // ── Stage 1: Framework-agnostic ───────────────────────────────── - - // Types - const types = await generateTypes(mizanSchema) - await writeOutput(path.join(fullOutputDir, 'types.ts'), types) - console.log('[mizan] Stage 1 -> types.ts') - - // Context files - await fs.mkdir(path.join(fullOutputDir, 'contexts'), { recursive: true }) - for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { - const content = generateContextFile(ctxName, ctxMeta, functions) - await writeOutput(path.join(fullOutputDir, 'contexts', `${ctxName}.ts`), content) - console.log(`[mizan] Stage 1 -> contexts/${ctxName}.ts`) - } - - // Mutation + function files - const regularFns = functions.filter(fn => !fn.isContext && !fn.isForm) - if (regularFns.length > 0) { - await fs.mkdir(path.join(fullOutputDir, 'mutations'), { recursive: true }) - await fs.mkdir(path.join(fullOutputDir, 'functions'), { recursive: true }) - - for (const fn of regularFns) { - const dir = fn.affects ? 'mutations' : 'functions' - const content = fn.affects ? generateMutationFile(fn) : generateFunctionFile(fn) - await writeOutput(path.join(fullOutputDir, dir, `${fn.camelName}.ts`), content) - console.log(`[mizan] Stage 1 -> ${dir}/${fn.camelName}.ts`) - } - } - - // Stage 1 index - const stage1Index = generateStage1Index(mizanSchema) - await writeOutput(path.join(fullOutputDir, 'index.ts'), stage1Index) - console.log('[mizan] Stage 1 -> index.ts') - - // ── Stage 2: Framework-specific ───────────────────────────────── - - for (const target of targets) { - let content - let filename - - switch (target) { - case 'react': - content = generateReactAdapter(mizanSchema) - filename = 'react.tsx' - break - case 'vue': - content = generateVueAdapter(mizanSchema) - filename = 'vue.ts' - break - case 'svelte': - content = generateSvelteAdapter(mizanSchema) - filename = 'svelte.ts' - break - default: - console.warn(`[mizan] Unknown target: ${target}`) - continue - } - - if (content) { - await writeOutput(path.join(fullOutputDir, filename), content) - console.log(`[mizan] Stage 2 -> ${filename}`) - } - } - - // Append Stage 2 re-exports to index.ts so `import { useEcho, MizanContext } from './api'` works - const adapterExports = targets - .map(t => ({ react: 'react', vue: 'vue', svelte: 'svelte' })[t]) - .filter(Boolean) - .map(name => `export * from './${name}'`) - .join('\n') - if (adapterExports) { - const indexPath = path.join(fullOutputDir, 'index.ts') - const existing = await fs.readFile(indexPath, 'utf8') - await writeOutput(indexPath, `${existing}\n// Stage 2 framework adapter\n${adapterExports}\n`) - } - - // Schema JSON - await writeOutput( - path.join(fullOutputDir, 'schema.json'), - JSON.stringify(mizanSchema, null, 2), - ) - - } catch (err) { - console.log(`[mizan] Schema not available: ${err.message}`) - } - - console.log('[mizan] Generation complete!') -} - -async function main() { - const args = process.argv.slice(2) - - let configPath = 'django.config.mjs' - let watchMode = false - let output = null - let target = null - - for (let i = 0; i < args.length; i++) { - if (args[i] === '--config' || args[i] === '-c') configPath = args[++i] - else if (args[i] === '--watch' || args[i] === '-w') watchMode = true - else if (args[i] === '--output' || args[i] === '-o') output = args[++i] - else if (args[i] === '--target' || args[i] === '-t') target = args[++i] - else if (args[i] === '--help' || args[i] === '-h') { - console.log(` -mizan Code Generator - -Usage: - npx mizan-generate [options] - -Options: - -c, --config Config file (default: django.config.mjs) - -t, --target Comma-separated: react,vue,svelte (default: react) - -o, --output Output directory (default: src/api) - -w, --watch Watch mode - -h, --help Show help -`) - process.exit(0) - } - } - - const config = await loadConfig(configPath) - const options = { output, target } - - if (watchMode) { - await generate(config, options) - console.log('[mizan] Watching for changes...') - const { watch: chokidarWatch } = await import('chokidar') - if (config.source.django) { - const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath)) - let timeout = null - const watcher = chokidarWatch([path.join(djangoDir, '**/*.py')], { - ignored: ['**/node_modules/**', '**/__pycache__/**', '**/migrations/**'], - ignoreInitial: true, - }) - watcher.on('change', () => { - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => generate(config, options), 1000) - }) - } - process.on('SIGINT', () => process.exit(0)) - } else { - await generate(config, options) - } -} - -main().catch(err => { - console.error('[mizan] Error:', err.message) - process.exit(1) -}) diff --git a/protocol/mizan-generate/generator/lib/adapters/react.mjs b/protocol/mizan-generate/generator/lib/adapters/react.mjs deleted file mode 100644 index 4d4cc86..0000000 --- a/protocol/mizan-generate/generator/lib/adapters/react.mjs +++ /dev/null @@ -1,298 +0,0 @@ -/** - * React Stage 2 — Generates idiomatic React providers + hooks on top of the kernel. - * - * The kernel (@mizan/base) owns data, status, error. This adapter wraps each - * registered context in a React Provider component so kernel subscription happens - * once per provider mount, and consumer hooks read from React Context. - * - * Output shape: - * root — calls configure(), auto-mounts global - * per-named-context provider - * - * - * - * - * useGlobalContext() full ContextState - * useCurrentUser() convenience: data field - * useUserContext() full ContextState - * useUserProfile() convenience: data field - * useEcho() mutation/plain — { mutate, isPending, error } - * useMizan() escape hatch — { call, fetch } - */ - -function pascalCase(str) { - return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') -} - -export function generateReactAdapter(schema) { - const functions = schema['x-mizan-functions'] || [] - const contextGroups = schema['x-mizan-contexts'] || {} - const namedContexts = Object.entries(contextGroups).filter(([n]) => n !== 'global') - const hasGlobal = !!contextGroups.global - const globalFns = functions.filter(fn => fn.isContext === 'global') - const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects) - const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects) - - const lines = [] - - // ── Header + imports ───────────────────────────────────────────────── - - lines.push( - "'use client'", - '', - '// AUTO-GENERATED by mizan — do not edit', - '', - "import {", - " createContext,", - " useCallback,", - " useContext,", - " useEffect,", - " useRef,", - " useState,", - " useSyncExternalStore,", - " type ReactNode,", - "} from 'react'", - "import {", - " configure,", - " initSession,", - " mizanCall,", - " mizanFetch,", - " MizanError,", - " registerContext,", - " type ContextState,", - "} from '@mizan/base'", - '', - ) - - const stage1Imports = [] - for (const [ctxName] of Object.entries(contextGroups)) { - const p = pascalCase(ctxName) - stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`) - } - for (const fn of [...mutations, ...plainFns]) { - stage1Imports.push(`call${pascalCase(fn.camelName)}`) - } - if (stage1Imports.length > 0) { - lines.push(`import { ${stage1Imports.join(', ')} } from './index'`, '') - } - - // ── Internal helper: subscribe to kernel state from a Provider ────── - - lines.push( - '// Internal — runs inside a Provider, registers with the kernel exactly once.', - 'function useContextSubscription(', - ' name: string,', - ' params: Record,', - ' fetchFn: () => Promise,', - ' initialData?: T,', - '): ContextState {', - ' const ref = useRef | null>(null)', - ' if (!ref.current) {', - ' ref.current = registerContext(name, params, fetchFn, initialData)', - ' }', - ' const handle = ref.current', - '', - ' useEffect(() => {', - " if (handle.getState().status === 'idle') handle.refetch()", - ' return () => handle.unregister()', - ' }, [handle])', - '', - ' return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)', - '}', - '', - ) - - // ── Internal helper: mutation wrapper ─────────────────────────────── - - lines.push( - '// Internal — wraps an imperative call() with isPending / error state.', - 'interface MutationHook {', - ' mutate: (args: TArgs) => Promise', - ' isPending: boolean', - ' error: Error | null', - '}', - '', - 'function useMutation(', - ' callFn: (args: TArgs) => Promise,', - '): MutationHook {', - ' const [isPending, setIsPending] = useState(false)', - ' const [error, setError] = useState(null)', - '', - ' const mutate = useCallback(async (args: TArgs) => {', - ' setIsPending(true)', - ' setError(null)', - ' try {', - ' return await callFn(args)', - ' } catch (e) {', - ' setError(e as Error)', - ' throw e', - ' } finally {', - ' setIsPending(false)', - ' }', - ' }, [callFn])', - '', - ' return { mutate, isPending, error }', - '}', - '', - ) - - // ── Global context provider + hooks ───────────────────────────────── - - if (hasGlobal) { - lines.push( - '// ── Global Context ──', - '', - 'const GlobalCtx = createContext | null>(null)', - '', - 'export function GlobalContextProvider({ children }: { children: ReactNode }) {', - " const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : undefined", - " const state = useContextSubscription('global', {}, () => fetchGlobalContext({} as any), ssrData)", - ' return {children}', - '}', - '', - 'export function useGlobalContext(): ContextState {', - ' const ctx = useContext(GlobalCtx)', - " if (!ctx) throw new Error('useGlobalContext requires or ')", - ' return ctx', - '}', - '', - ) - - for (const fn of globalFns) { - const p = pascalCase(fn.camelName) - lines.push( - `export function use${p}(): ${fn.outputType} | null {`, - ` return useGlobalContext().data?.${fn.name} ?? null`, - '}', - '', - ) - } - } - - // ── Named context providers + hooks ───────────────────────────────── - - for (const [ctxName, ctxMeta] of namedContexts) { - const p = pascalCase(ctxName) - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - const paramKeys = Object.keys(ctxMeta.params || {}) - const hasParams = paramKeys.length > 0 - - lines.push( - `// ── ${p} Context ──`, - '', - `const ${p}Ctx = createContext | null>(null)`, - '', - ) - - if (hasParams) { - lines.push( - `export function ${p}Context({ children, ...params }: ${p}ContextParams & { children: ReactNode }) {`, - ` const state = useContextSubscription('${ctxName}', params, () => fetch${p}Context(params))`, - ` return <${p}Ctx.Provider value={state}>{children}`, - '}', - ) - } else { - lines.push( - `export function ${p}Context({ children }: { children: ReactNode }) {`, - ` const state = useContextSubscription('${ctxName}', {}, () => fetch${p}Context({} as any))`, - ` return <${p}Ctx.Provider value={state}>{children}`, - '}', - ) - } - lines.push('') - - lines.push( - `export function use${p}Context(): ContextState<${p}ContextData> {`, - ` const ctx = useContext(${p}Ctx)`, - ` if (!ctx) throw new Error('use${p}Context requires <${p}Context>')`, - ' return ctx', - '}', - '', - ) - - for (const fn of ctxFunctions) { - const fnPascal = pascalCase(fn.camelName) - lines.push( - `export function use${fnPascal}(): ${fn.outputType} | null {`, - ` return use${p}Context().data?.${fn.name} ?? null`, - '}', - '', - ) - } - } - - // ── Mutation + plain function hooks ───────────────────────────────── - - for (const fn of [...mutations, ...plainFns]) { - const p = pascalCase(fn.camelName) - if (fn.hasInput) { - lines.push( - `export function use${p}() {`, - ` return useMutation[0], Awaited>>(call${p})`, - '}', - '', - ) - } else { - lines.push( - `export function use${p}() {`, - ` return useMutation>>(() => call${p}() as any)`, - '}', - '', - ) - } - } - - // ── Root MizanContext provider ────────────────────────────────────── - - lines.push( - '// ── MizanContext root provider ──', - '', - 'export interface MizanContextProps {', - ' /** Base URL for protocol endpoints. Defaults to "/api/mizan". */', - ' baseUrl?: string', - ' children: ReactNode', - '}', - '', - '/**', - " * Root provider — calls configure() once and mounts the global context (if defined).", - ' * Must wrap any component using Mizan-generated hooks.', - ' */', - 'export function MizanContext({ baseUrl, children }: MizanContextProps) {', - ' const configured = useRef(false)', - ' if (!configured.current) {', - ' if (baseUrl) configure({ baseUrl })', - ' configured.current = true', - ' }', - ) - if (hasGlobal) { - lines.push(' return {children}') - } else { - lines.push(' return <>{children}') - } - lines.push('}', '') - - // ── Escape hatch: useMizan ────────────────────────────────────────── - - lines.push( - '// ── Imperative escape hatch ──', - '', - '/**', - ' * Returns the imperative kernel API. For test harnesses or rare cases where', - ' * a typed generated hook does not fit. Most app code should use the typed hooks.', - ' */', - 'export function useMizan() {', - ' return { call: mizanCall, fetch: mizanFetch }', - '}', - '', - ) - - // ── Re-exports ────────────────────────────────────────────────────── - - lines.push( - "export type { ContextState } from '@mizan/base'", - "export { configure, initSession, MizanError } from '@mizan/base'", - '', - ) - - return lines.join('\n') -} diff --git a/protocol/mizan-generate/generator/lib/adapters/svelte.mjs b/protocol/mizan-generate/generator/lib/adapters/svelte.mjs deleted file mode 100644 index bc580fb..0000000 --- a/protocol/mizan-generate/generator/lib/adapters/svelte.mjs +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Svelte Stage 2 — Generates stores from Stage 1 output. - * - * Subscribes to the kernel for state. Returns readable stores. - */ - -function pascalCase(str) { - return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') -} - -export function generateSvelteAdapter(schema) { - const functions = schema['x-mizan-functions'] || [] - const contextGroups = schema['x-mizan-contexts'] || {} - const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects) - const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects) - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - "import { readable, type Readable } from 'svelte/store'", - "import { registerContext, type ContextState } from '@mizan/base'", - '', - ] - - const stage1Imports = [] - for (const [ctxName] of Object.entries(contextGroups)) { - const p = pascalCase(ctxName) - stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`) - } - for (const fn of [...mutations, ...plainFns]) { - stage1Imports.push(`call${pascalCase(fn.camelName)}`) - } - if (stage1Imports.length > 0) { - lines.push(`import { ${stage1Imports.join(', ')} } from '../index'`) - lines.push('') - } - - for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { - const p = pascalCase(ctxName) - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - const paramEntries = Object.entries(ctxMeta.params || {}) - const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any' - - if (paramEntries.length > 0) { - lines.push(`export function create${p}Context(params: ${p}ContextParams) {`) - } else { - lines.push(`export function create${p}Context() {`) - } - - // Use readable store backed by kernel subscription - lines.push(` const store = readable>(`) - lines.push(` { data: null, status: 'idle', error: null },`) - lines.push(` (set) => {`) - lines.push(` const handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) - lines.push(` const unsub = handle.subscribe(() => set(handle.getState()))`) - lines.push(` handle.refetch()`) - lines.push(` return () => { unsub(); handle.unregister() }`) - lines.push(` },`) - lines.push(` )`) - lines.push('') - lines.push(` return store`) - lines.push('}') - lines.push('') - } - - // Re-export mutations as-is from Stage 1 - for (const fn of [...mutations, ...plainFns]) { - const p = pascalCase(fn.camelName) - lines.push(`export { call${p} } from '../index'`) - } - lines.push('') - - lines.push("export type { ContextState } from '@mizan/base'") - lines.push("export { configure, initSession, MizanError } from '@mizan/base'") - lines.push('') - - return lines.join('\n') -} diff --git a/protocol/mizan-generate/generator/lib/adapters/vue.mjs b/protocol/mizan-generate/generator/lib/adapters/vue.mjs deleted file mode 100644 index 13ce58a..0000000 --- a/protocol/mizan-generate/generator/lib/adapters/vue.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Vue Stage 2 — Generates composables from Stage 1 output. - * - * Subscribes to the kernel for state. Vue reactivity wraps kernel notifications. - */ - -function pascalCase(str) { - return str.split(/[.\-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('') -} - -export function generateVueAdapter(schema) { - const functions = schema['x-mizan-functions'] || [] - const contextGroups = schema['x-mizan-contexts'] || {} - const mutations = functions.filter(fn => !fn.isContext && !fn.isForm && fn.affects) - const plainFns = functions.filter(fn => !fn.isContext && !fn.isForm && !fn.affects) - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - "import { ref, computed, onMounted, onUnmounted, onServerPrefetch, type ComputedRef } from 'vue'", - "import { registerContext, type ContextState } from '@mizan/base'", - '', - ] - - const stage1Imports = [] - for (const [ctxName] of Object.entries(contextGroups)) { - const p = pascalCase(ctxName) - stage1Imports.push(`fetch${p}Context`, `type ${p}ContextData`, `type ${p}ContextParams`) - } - for (const fn of [...mutations, ...plainFns]) { - stage1Imports.push(`call${pascalCase(fn.camelName)}`) - } - if (stage1Imports.length > 0) { - lines.push(`import { ${stage1Imports.join(', ')} } from '../index'`) - lines.push('') - } - - for (const [ctxName, ctxMeta] of Object.entries(contextGroups)) { - const p = pascalCase(ctxName) - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - const paramEntries = Object.entries(ctxMeta.params || {}) - const paramsArg = paramEntries.length > 0 ? 'params' : '{} as any' - - if (paramEntries.length > 0) { - lines.push(`export function use${p}Context(params: ${p}ContextParams) {`) - } else { - lines.push(`export function use${p}Context() {`) - } - - lines.push(` const state = ref>({ data: null, status: 'idle', error: null })`) - lines.push(` let handle: ReturnType | null = null`) - lines.push('') - lines.push(` onMounted(() => {`) - lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) - lines.push(` handle.subscribe(() => { state.value = handle!.getState() })`) - lines.push(` handle.refetch()`) - lines.push(` })`) - lines.push('') - lines.push(` onServerPrefetch(async () => {`) - lines.push(` handle = registerContext('${ctxName}', ${paramsArg}, () => fetch${p}Context(${paramsArg}))`) - lines.push(` await handle.refetch()`) - lines.push(` state.value = handle.getState()`) - lines.push(` })`) - lines.push('') - lines.push(` onUnmounted(() => { handle?.unregister() })`) - lines.push('') - lines.push(` return {`) - lines.push(` state,`) - for (const fn of ctxFunctions) { - lines.push(` ${fn.camelName}: computed(() => state.value.data?.${fn.name} ?? null) as ComputedRef<${fn.outputType} | null>,`) - } - lines.push(` loading: computed(() => state.value.status === 'loading'),`) - lines.push(` error: computed(() => state.value.error),`) - lines.push(` }`) - lines.push('}') - lines.push('') - } - - for (const fn of [...mutations, ...plainFns]) { - const p = pascalCase(fn.camelName) - lines.push(`export function use${p}() {`) - lines.push(` const isPending = ref(false)`) - lines.push(` const error = ref(null)`) - if (fn.hasInput) { - lines.push(` async function mutate(args: Parameters[0]) {`) - } else { - lines.push(` async function mutate() {`) - } - lines.push(` isPending.value = true; error.value = null`) - lines.push(` try { return await call${p}(${fn.hasInput ? 'args' : ''}) }`) - lines.push(` catch (e) { error.value = e as Error; throw e }`) - lines.push(` finally { isPending.value = false }`) - lines.push(` }`) - lines.push(` return { mutate, isPending, error }`) - lines.push('}') - lines.push('') - } - - lines.push("export type { ContextState } from '@mizan/base'") - lines.push("export { configure, initSession, MizanError } from '@mizan/base'") - lines.push('') - - return lines.join('\n') -} diff --git a/protocol/mizan-generate/generator/lib/channels.mjs b/protocol/mizan-generate/generator/lib/channels.mjs deleted file mode 100644 index d634c37..0000000 --- a/protocol/mizan-generate/generator/lib/channels.mjs +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Channels Code Generator - * - * Generates TypeScript types and React hooks from Channels OpenAPI schema. - * Uses openapi-typescript for robust type generation. - */ - -import openapiTS, { astToString } from 'openapi-typescript' - -/** - * Generate channels TypeScript types using openapi-typescript. - */ -export async function generateChannelsTypes(schema) { - // Generate types using openapi-typescript - const ast = await openapiTS(schema) - const typesCode = astToString(ast) - - const lines = [ - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - '// ============================================================================', - '// OpenAPI Types (generated by openapi-typescript)', - '// ============================================================================', - '', - typesCode, - '', - ] - - // Extract channel metadata from x-mizan-channels extension - const channels = schema['x-mizan-channels'] || [] - - if (channels.length > 0) { - lines.push('// ============================================================================') - lines.push('// Convenience Type Exports') - lines.push('// ============================================================================') - lines.push('') - - for (const channel of channels) { - if (channel.hasParams) { - lines.push(`export type ${channel.paramsType} = components["schemas"]["${channel.paramsType}"]`) - } - if (channel.hasReactMessage) { - lines.push(`export type ${channel.reactMessageType} = components["schemas"]["${channel.reactMessageType}"]`) - } - if (channel.hasDjangoMessage) { - lines.push(`export type ${channel.djangoMessageType} = components["schemas"]["${channel.djangoMessageType}"]`) - } - } - - lines.push('') - lines.push('// ============================================================================') - lines.push('// Channel Registry') - lines.push('// ============================================================================') - lines.push('') - lines.push('export const CHANNELS = {') - for (const channel of channels) { - lines.push(` ${channel.name}: {`) - lines.push(` name: '${channel.name}',`) - lines.push(` pascalName: '${channel.pascalName}',`) - lines.push(` hasParams: ${channel.hasParams},`) - lines.push(` hasReactMessage: ${channel.hasReactMessage},`) - lines.push(` hasDjangoMessage: ${channel.hasDjangoMessage},`) - if (channel.hasParams) { - lines.push(` paramsType: '${channel.paramsType}',`) - } - if (channel.hasReactMessage) { - lines.push(` reactMessageType: '${channel.reactMessageType}',`) - } - if (channel.hasDjangoMessage) { - lines.push(` djangoMessageType: '${channel.djangoMessageType}',`) - } - lines.push(` },`) - } - lines.push('} as const') - } else { - lines.push('export const CHANNELS = {} as const') - } - - lines.push('') - - return lines.join('\n') -} - -/** - * Generate channel hooks from metadata. - */ -export function generateChannelsHooks(schema) { - const channels = schema['x-mizan-channels'] || [] - - if (channels.length === 0) { - return null - } - - const lines = [ - "'use client'", - '', - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - "import { useChannel, type ChannelSubscription } from 'mizan/channels'", - '', - ] - - // Collect type imports - const typeImports = [] - for (const channel of channels) { - if (channel.hasParams) typeImports.push(channel.paramsType) - if (channel.hasReactMessage) typeImports.push(channel.reactMessageType) - if (channel.hasDjangoMessage) typeImports.push(channel.djangoMessageType) - } - - if (typeImports.length > 0) { - lines.push(`import type { ${typeImports.join(', ')} } from './generated.channels'`) - lines.push('') - } - - // Generate hooks for each channel - lines.push('// ============================================================================') - lines.push('// Channel Hooks') - lines.push('// ============================================================================') - lines.push('') - - for (const channel of channels) { - const paramsType = channel.hasParams ? channel.paramsType : 'Record' - const reactMsgType = channel.hasReactMessage ? channel.reactMessageType : 'never' - const djangoMsgType = channel.hasDjangoMessage ? channel.djangoMessageType : 'never' - - lines.push(`/**`) - lines.push(` * Hook for the ${channel.name} channel.`) - lines.push(` */`) - - if (channel.hasParams) { - lines.push(`export function use${channel.pascalName}Channel(params: ${paramsType}): ChannelSubscription<${paramsType}, ${djangoMsgType}, ${reactMsgType}> {`) - lines.push(` return useChannel('${channel.name}', params)`) - } else { - lines.push(`export function use${channel.pascalName}Channel(): ChannelSubscription, ${djangoMsgType}, ${reactMsgType}> {`) - lines.push(` return useChannel('${channel.name}', {})`) - } - lines.push('}') - lines.push('') - } - - return lines.join('\n') -} - -/** - * Generate all channels files. - */ -export async function generateChannelsFiles(schema) { - const types = await generateChannelsTypes(schema) - const hooks = generateChannelsHooks(schema) - - return { types, hooks } -} diff --git a/protocol/mizan-generate/generator/lib/fetch.mjs b/protocol/mizan-generate/generator/lib/fetch.mjs deleted file mode 100644 index 26d4100..0000000 --- a/protocol/mizan-generate/generator/lib/fetch.mjs +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Schema Fetching — dispatches on the backend type configured in - * `source.django` or `source.fastapi`. - * - * Both flavors spawn a Python subprocess that prints schema JSON to stdout: - * Django: `python manage.py export_mizan_schema --indent 0` - * FastAPI: `python -m mizan_fastapi.cli ` - */ - -import { spawn } from 'child_process' -import path from 'path' - - -function runSubprocess(cmd, args, opts) { - const { cwd, env, label } = opts - return new Promise((resolve, reject) => { - const proc = spawn(cmd, args, { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - env, - }) - - let stdout = '' - let stderr = '' - - proc.stdout.on('data', (data) => { stdout += data.toString() }) - proc.stderr.on('data', (data) => { stderr += data.toString() }) - - proc.on('close', (code) => { - if (code !== 0) { - reject(new Error(`${label} command failed (exit ${code}):\n${stderr}`)) - return - } - - const jsonStart = stdout.indexOf('{') - if (jsonStart === -1) { - reject(new Error(`No JSON found in ${label} output:\n${stdout}\n${stderr}`)) - return - } - - try { - resolve(JSON.parse(stdout.slice(jsonStart))) - } catch (err) { - reject(new Error(`Failed to parse JSON from ${label}:\n${err.message}\n${stdout}`)) - } - }) - - proc.on('error', (err) => { - reject(new Error(`Failed to spawn ${label} command: ${err.message}`)) - }) - }) -} - - -function runDjangoCommand(source, cwd, command) { - const managePath = path.resolve(cwd, source.django.managePath) - const manageDir = path.dirname(managePath) - - let cmd, args - if (source.django.command) { - cmd = source.django.command[0] - args = [...source.django.command.slice(1), 'manage.py', command, '--indent', '0'] - } else { - const python = source.django.python || 'python' - cmd = python - args = [managePath, command, '--indent', '0'] - } - - const env = source.django.env ? { ...process.env, ...source.django.env } : undefined - return runSubprocess(cmd, args, { cwd: manageDir, env, label: 'Django' }) -} - - -function runFastapiSchemaCommand(source, cwd) { - const fastapiCwd = source.fastapi.cwd - ? path.resolve(cwd, source.fastapi.cwd) - : cwd - - let cmd, args - if (source.fastapi.command) { - cmd = source.fastapi.command[0] - args = [...source.fastapi.command.slice(1), '-m', 'mizan_fastapi.cli', source.fastapi.module] - } else { - cmd = source.fastapi.python || 'python' - args = ['-m', 'mizan_fastapi.cli', source.fastapi.module] - } - - const env = source.fastapi.env ? { ...process.env, ...source.fastapi.env } : undefined - return runSubprocess(cmd, args, { cwd: fastapiCwd, env, label: 'FastAPI' }) -} - - -/** - * Fetch channels schema. Channels are a Django-only feature; FastAPI - * projects use native WebSockets and don't go through this path. - */ -export async function fetchChannelsSchema(source, cwd) { - if (!source.django) { - throw new Error('Channels schema export requires django source configuration') - } - return runDjangoCommand(source, cwd, 'export_channels_schema') -} - - -/** - * Fetch mizan schema. Dispatches on whichever backend source is configured. - */ -export async function fetchMizanSchema(source, cwd) { - if (source.fastapi) { - return runFastapiSchemaCommand(source, cwd) - } - if (source.django) { - return runDjangoCommand(source, cwd, 'export_mizan_schema') - } - throw new Error('mizan schema export requires source.django or source.fastapi') -} diff --git a/protocol/mizan-generate/generator/lib/index.mjs b/protocol/mizan-generate/generator/lib/index.mjs deleted file mode 100644 index 8a19ac8..0000000 --- a/protocol/mizan-generate/generator/lib/index.mjs +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Index File Generator - * - * Generates a consolidated index.ts that re-exports everything - * from the generated files for clean imports. - */ - -function pascalCase(str) { - return str.charAt(0).toUpperCase() + str.slice(1) -} - -function toPascalCase(str) { - return str - .split(/[.\-_]/) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') -} - -/** - * Generate the consolidated index.ts file. - */ -export function generateIndex({ channelsSchema, mizanSchema }) { - const lines = [ - '/**', - ' * mizan API - Consolidated Exports', - ' *', - ' * Import everything from here:', - ' *', - ' * @example', - ' * ```tsx', - ' * import {', - ' * MizanContext,', - ' * useCurrentUser,', - ' * useEcho,', - ' * useChatChannel,', - ' * } from \'@/api\'', - ' * ```', - ' */', - '', - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - ] - - const functions = mizanSchema?.['x-mizan-functions'] || [] - const contextGroups = mizanSchema?.['x-mizan-contexts'] || {} - const hasMizan = functions.length > 0 - - if (hasMizan) { - const globalContexts = functions.filter(fn => fn.isContext === 'global') - const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) - const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global') - - lines.push('// =============================================================================') - lines.push('// mizan Provider & Hooks') - lines.push('// =============================================================================') - lines.push('') - - // Server exports - if (globalContexts.length > 0) { - lines.push('export {') - lines.push(' getMizanHydration,') - lines.push(' getDjangoHydration,') - lines.push(' type MizanHydrationData,') - lines.push(' type DjangoHydration,') - lines.push("} from './generated.server'") - lines.push('') - } - - // Client exports - lines.push('export {') - lines.push(' // Provider') - lines.push(' MizanContext,') - lines.push(' type MizanContextProps,') - lines.push(' DjangoContext,') - lines.push(' type DjangoContextProps,') - - // Global context hooks - if (globalContexts.length > 0) { - lines.push('') - lines.push(' // Global context hooks') - for (const ctx of globalContexts) { - const hookPascal = pascalCase(ctx.camelName) - lines.push(` use${hookPascal},`) - } - lines.push('') - lines.push(' // Refresh hooks') - lines.push(' useMizanRefresh,') - lines.push(' useDjangoRefresh,') - } - - // Named context providers and hooks - if (namedContextEntries.length > 0) { - lines.push('') - lines.push(' // Named context providers') - for (const [ctxName, ctxMeta] of namedContextEntries) { - const ctxPascal = toPascalCase(ctxName) - lines.push(` ${ctxPascal}Context,`) - // Hooks for this context's functions - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - for (const fn of ctxFunctions) { - const hookPascal = pascalCase(fn.camelName) - lines.push(` use${hookPascal},`) - } - } - } - - // Function hooks (mutations + plain) - if (regularFunctions.length > 0) { - lines.push('') - lines.push(' // Function hooks') - for (const fn of regularFunctions) { - const pascal = pascalCase(fn.camelName) - lines.push(` use${pascal},`) - } - } - - lines.push('') - lines.push(' // Re-exports from mizan library') - lines.push(' useMizan,') - lines.push(' useMizanStatus,') - lines.push(' usePush,') - lines.push(' DjangoError,') - lines.push(' type ConnectionStatus,') - lines.push(' type PushMessage,') - lines.push(' type PushListener,') - lines.push("} from './generated.provider'") - lines.push('') - } - - // ========================================================================== - // Channel Hooks - // ========================================================================== - - const channels = channelsSchema?.['x-mizan-channels'] || [] - - if (channels.length > 0) { - lines.push('// =============================================================================') - lines.push('// Channel Hooks') - lines.push('// =============================================================================') - lines.push('') - lines.push('export {') - for (const ch of channels) { - lines.push(` use${ch.pascalName}Channel,`) - } - lines.push("} from './generated.channels.hooks'") - lines.push('') - - lines.push('// =============================================================================') - lines.push('// Channel Types') - lines.push('// =============================================================================') - lines.push('') - lines.push('export type {') - for (const ch of channels) { - if (ch.hasParams) lines.push(` ${ch.paramsType},`) - if (ch.hasReactMessage) lines.push(` ${ch.reactMessageType},`) - if (ch.hasDjangoMessage) lines.push(` ${ch.djangoMessageType},`) - } - lines.push("} from './generated.channels'") - lines.push('') - } - - return lines.join('\n') -} diff --git a/protocol/mizan-generate/generator/lib/mizan.mjs b/protocol/mizan-generate/generator/lib/mizan.mjs deleted file mode 100644 index f8b455b..0000000 --- a/protocol/mizan-generate/generator/lib/mizan.mjs +++ /dev/null @@ -1,980 +0,0 @@ -/** - * mizan Code Generator - * - * Generates TypeScript types and React provider from mizan OpenAPI schema. - * Uses openapi-typescript for robust type generation. - * - * Output structure: - * - generated.mizan.ts - Types only (from OpenAPI) - * - generated.provider.tsx - Typed provider wrapping MizanProvider + hooks - * - generated.forms.ts - Typed form hooks with Zod schemas - */ - -import openapiTS, { astToString } from 'openapi-typescript' - -// TypeScript SyntaxKind values for AST manipulation -const SyntaxKind = { - InterfaceDeclaration: 265, - TypeAliasDeclaration: 266, - PropertySignature: 172, - TypeReference: 184, - IndexedAccessType: 200, - Identifier: 80, - StringLiteral: 11, -} - -/** - * Get identifier name from AST node. - */ -function idName(node) { - return node?.kind === SyntaxKind.Identifier ? node.escapedText : undefined -} - -/** - * Extract schema names from openapi-typescript AST. - */ -function getSchemaNamesFromAst(ast) { - if (!Array.isArray(ast)) return [] - - const componentsNode = ast.find( - node => - node?.kind === SyntaxKind.InterfaceDeclaration && - idName(node?.name) === 'components' - ) - - if (!componentsNode?.members) return [] - - const schemasProp = componentsNode.members.find( - member => - member?.kind === SyntaxKind.PropertySignature && - idName(member?.name) === 'schemas' && - Array.isArray(member?.type?.members) - ) - - if (!schemasProp) return [] - - return schemasProp.type.members - .map(member => - member?.kind === SyntaxKind.PropertySignature ? idName(member.name) : undefined - ) - .filter(n => typeof n === 'string') -} - -/** - * Build convenience type exports for schemas. - */ -function buildSchemaExports(schemaNames) { - if (!schemaNames.length) return '' - - return schemaNames - .map(name => `export type ${name} = components["schemas"]["${name}"]`) - .join('\n') -} - -/** - * Generate the types file using openapi-typescript. - */ -export async function generateMizanTypes(schema) { - // Generate types using openapi-typescript - const ast = await openapiTS(schema) - const schemaNames = getSchemaNamesFromAst(ast) - const typesCode = astToString(ast) - - const lines = [ - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - '// ============================================================================', - '// OpenAPI Types (generated by openapi-typescript)', - '// ============================================================================', - '', - typesCode, - '', - '// ============================================================================', - '// Convenience Type Exports', - '// ============================================================================', - '', - buildSchemaExports(schemaNames), - '', - '// ============================================================================', - '// Function Registry (for reference)', - '// ============================================================================', - '', - "export type Transport = 'http' | 'websocket' | 'both'", - '', - ] - - // Extract function metadata from x-mizan-functions extension - const functions = schema['x-mizan-functions'] || [] - - if (functions.length > 0) { - lines.push('export const MIZAN_FUNCTIONS = {') - for (const fn of functions) { - lines.push(` ${fn.camelName}: {`) - lines.push(` name: '${fn.name}',`) - lines.push(` hasInput: ${fn.hasInput},`) - lines.push(` isContext: ${fn.isContext},`) - lines.push(` transport: '${fn.transport}' as Transport,`) - lines.push(` },`) - } - lines.push('} as const') - } else { - lines.push('export const MIZAN_FUNCTIONS = {} as const') - } - - lines.push('') - - return lines.join('\n') -} - -/** - * Extract unique context names from an affects array. - * Both context-level and function-level affects resolve to context names. - */ -function getAffectedContexts(affects) { - const contexts = new Set() - for (const target of affects) { - if (target.type === 'context') { - contexts.add(target.name) - } else if (target.type === 'function' && target.context) { - contexts.add(target.context) - } - } - return [...contexts] -} - -/** - * Map JSON schema type string to TypeScript type. - */ -function jsonTypeToTS(type) { - if (type === 'integer' || type === 'number') return 'number' - if (type === 'boolean') return 'boolean' - return 'string' -} - -/** - * Generate the React provider that wraps MizanProvider with typed hooks. - * - * The generated provider: - * - MizanContext: Root provider with global context bundled fetch - * - Named context providers: - * - Mutation hooks with auto-invalidation - * - Plain function hooks - */ -export function generateMizanProvider(schema, options = {}) { - const { hasChannels = false } = options - const functions = schema['x-mizan-functions'] || [] - const contextGroups = schema['x-mizan-contexts'] || {} - - if (functions.length === 0) { - return null - } - - // Partition functions - const globalContexts = functions.filter(fn => fn.isContext === 'global') - const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm) - const mutationFunctions = regularFunctions.filter(fn => fn.affects) - const plainFunctions = regularFunctions.filter(fn => !fn.affects) - - // Named context groups (everything except 'global') - const namedContextEntries = Object.entries(contextGroups).filter(([name]) => name !== 'global') - - // Collect type imports - const typeImports = [] - for (const fn of functions) { - if (fn.hasInput && fn.inputType) { - typeImports.push(fn.inputType) - } - if (fn.outputType) { - typeImports.push(fn.outputType) - } - } - const uniqueTypeImports = [...new Set(typeImports)].sort() - - const lines = [ - "'use client'", - '', - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - '// This file provides typed wrappers around the mizan library.', - '// - MizanContext: Root provider with global context', - '// - Named context providers: ', - '// - Typed hooks with auto-invalidation', - '', - "import { type ReactNode, useCallback, useState, useEffect, useRef, createContext, useContext } from 'react'", - "import {", - " MizanProvider,", - " useMizan,", - " useMizanContext,", - " useMizanCall,", - " type MizanHydration,", - " type Transport,", - "} from 'mizan'", - ...(hasChannels ? [ - "import { ChannelProvider, ChannelConnection } from 'mizan/channels'", - ] : []), - '', - ] - - if (uniqueTypeImports.length > 0) { - lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`) - lines.push('') - } - - // ============================================================================ - // Hydration types (global contexts only) - // ============================================================================ - - lines.push('// ============================================================================') - lines.push('// Hydration Types') - lines.push('// ============================================================================') - lines.push('') - - if (globalContexts.length > 0) { - lines.push('/** Typed hydration data for SSR (global contexts only) */') - lines.push('export interface MizanHydrationData {') - for (const ctx of globalContexts) { - lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) - } - lines.push('}') - lines.push('') - - lines.push('function toMizanHydration(hydration?: MizanHydrationData): MizanHydration | undefined {') - lines.push(' if (!hydration) return undefined') - lines.push(' const result: MizanHydration = {}') - for (const ctx of globalContexts) { - lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`) - } - lines.push(' return result') - lines.push('}') - lines.push('') - } - - // ============================================================================ - // Global Context Loader (inner component, fetches GET /ctx/global/) - // ============================================================================ - - if (globalContexts.length > 0) { - lines.push('// ============================================================================') - lines.push('// Global Context Loader') - lines.push('// ============================================================================') - lines.push('') - lines.push('function GlobalContextLoader({ children }: { children: ReactNode }) {') - lines.push(' const mizan = useMizan()') - lines.push(' const loaded = useRef(false)') - lines.push('') - lines.push(' useEffect(() => {') - lines.push(' if (loaded.current) return') - lines.push(' loaded.current = true') - lines.push('') - lines.push(' // Check for SSR hydration data first') - lines.push(" const ssr = typeof window !== 'undefined' && (window as any).__MIZAN_SSR_DATA__") - lines.push(' if (ssr) {') - lines.push(' for (const [name, data] of Object.entries(ssr)) {') - lines.push(' mizan.setContextData(name, data)') - lines.push(' }') - lines.push(' return') - lines.push(' }') - lines.push('') - lines.push(' ;(async () => {') - lines.push(' await mizan.whenReady') - lines.push(' try {') - lines.push(" const response = await mizan.request('GET', `${mizan.baseUrl}/ctx/global/`)") - lines.push(' const result = await response.json()') - lines.push(' for (const [name, data] of Object.entries(result)) {') - lines.push(' mizan.setContextData(name, data)') - lines.push(' }') - lines.push(' } catch (e) {') - lines.push(" console.error('[MizanContext] Global context fetch failed:', e)") - lines.push(' }') - lines.push(' })()') - lines.push(' }, [mizan])') - lines.push('') - lines.push(' return <>{children}') - lines.push('}') - lines.push('') - } - - // ============================================================================ - // Root Provider (MizanContext) - // ============================================================================ - - lines.push('// ============================================================================') - lines.push('// Root Provider') - lines.push('// ============================================================================') - lines.push('') - - lines.push('export interface MizanContextProps {') - lines.push(' children: ReactNode') - if (globalContexts.length > 0) { - lines.push(' /** SSR hydration data (global contexts only) */') - lines.push(' hydration?: MizanHydrationData') - } - lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */') - lines.push(' wsUrl?: string') - lines.push(' /** Base URL for HTTP calls (default: /api/mizan) */') - lines.push(' baseUrl?: string') - lines.push('}') - lines.push('') - - lines.push('/**') - lines.push(' * Root mizan provider. Mount at your app root.') - lines.push(' *') - lines.push(' * Usage:') - lines.push(' * ') - lines.push(' * ') - lines.push(' * ') - lines.push(' */') - lines.push('export function MizanContext({') - lines.push(' children,') - if (globalContexts.length > 0) { - lines.push(' hydration,') - } - lines.push(' wsUrl,') - lines.push(' baseUrl,') - lines.push('}: MizanContextProps) {') - - if (hasChannels) { - lines.push(' const connectionRef = useRef(null)') - lines.push(' if (!connectionRef.current) {') - lines.push(" connectionRef.current = new ChannelConnection({ url: wsUrl || '/ws/' })") - lines.push(' }') - lines.push('') - } - - // Build the JSX tree - lines.push(' return (') - lines.push(' 0) { - lines.push(' hydration={toMizanHydration(hydration)}') - } - lines.push(' wsUrl={wsUrl}') - lines.push(' baseUrl={baseUrl}') - if (hasChannels) { - lines.push(' connection={connectionRef.current}') - } - lines.push(' >') - - // Inner content: GlobalContextLoader wraps children if needed - let innerContent = '{children}' - if (globalContexts.length > 0) { - innerContent = `{children}` - } - - if (hasChannels) { - lines.push(` `) - lines.push(` ${innerContent}`) - lines.push(` `) - } else { - lines.push(` ${innerContent}`) - } - - lines.push(' ') - lines.push(' )') - lines.push('}') - lines.push('') - - // Legacy alias - lines.push('/** @deprecated Use MizanContext instead */') - lines.push('export const DjangoContext = MizanContext') - lines.push('/** @deprecated Use MizanContextProps instead */') - lines.push('export type DjangoContextProps = MizanContextProps') - if (globalContexts.length > 0) { - lines.push('/** @deprecated Use MizanHydrationData instead */') - lines.push('export type DjangoHydration = MizanHydrationData') - } - lines.push('') - - // ============================================================================ - // Global Context Hooks - // ============================================================================ - - if (globalContexts.length > 0) { - lines.push('// ============================================================================') - lines.push('// Global Context Hooks') - lines.push('// ============================================================================') - lines.push('') - - for (const ctx of globalContexts) { - const pascal = pascalCase(ctx.camelName) - lines.push(`/** Get ${ctx.name} context data. @throws if not loaded yet */`) - lines.push(`export function use${pascal}(): ${ctx.outputType} {`) - lines.push(` const data = useMizanContext<${ctx.outputType}>('${ctx.name}')`) - lines.push(` if (data === undefined) throw new Error('use${pascal}: context not loaded yet')`) - lines.push(` return data`) - lines.push(`}`) - lines.push('') - } - - lines.push('/** Refresh functions for global contexts. */') - lines.push('export function useMizanRefresh() {') - lines.push(' const { invalidateContext } = useMizan()') - lines.push(' return {') - for (const ctx of globalContexts) { - const pascal = pascalCase(ctx.camelName) - lines.push(` refresh${pascal}: () => invalidateContext('${ctx.name}'),`) - } - lines.push(' }') - lines.push('}') - lines.push('') - - // Legacy alias - lines.push('/** @deprecated Use useMizanRefresh instead */') - lines.push('export const useDjangoRefresh = useMizanRefresh') - lines.push('') - } - - // ============================================================================ - // Named Context Providers - // ============================================================================ - - if (namedContextEntries.length > 0) { - lines.push('// ============================================================================') - lines.push('// Named Context Providers') - lines.push('// ============================================================================') - lines.push('') - - for (const [ctxName, ctxMeta] of namedContextEntries) { - const ctxPascal = toPascalCase(ctxName) - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - const params = ctxMeta.params || {} - const paramEntries = Object.entries(params) - - // Internal React context type - lines.push(`const ${ctxPascal}ContextInternal = createContext<{`) - for (const fn of ctxFunctions) { - lines.push(` ${fn.name}: ${fn.outputType}`) - } - lines.push(`} | null>(null)`) - lines.push('') - - // Props interface - lines.push(`export interface ${ctxPascal}ContextProps {`) - lines.push(` children: ReactNode`) - for (const [pName, pMeta] of paramEntries) { - const tsType = jsonTypeToTS(pMeta.type) - const optional = pMeta.required ? '' : '?' - lines.push(` ${pName}${optional}: ${tsType}`) - } - lines.push(`}`) - lines.push('') - - // Provider component - lines.push(`export function ${ctxPascal}Context({ children, ...params }: ${ctxPascal}ContextProps) {`) - lines.push(` const mizan = useMizan()`) - - // SSR hydration check — initialize from __MIZAN_SSR_DATA__ if available - lines.push(` const [data, setData] = useState<{`) - for (const fn of ctxFunctions) { - lines.push(` ${fn.name}: ${fn.outputType}`) - } - lines.push(` } | null>(() => {`) - lines.push(` if (typeof window === 'undefined') return null`) - lines.push(` const ssr = (window as any).__MIZAN_SSR_DATA__`) - lines.push(` if (!ssr) return null`) - // Check if all functions for this context have SSR data - const firstFn = ctxFunctions[0] - lines.push(` if (ssr.${firstFn.name} === undefined) return null`) - lines.push(` return {`) - for (const fn of ctxFunctions) { - lines.push(` ${fn.name}: ssr.${fn.name},`) - } - lines.push(` }`) - lines.push(` })`) - lines.push('') - lines.push(` const refetch = useCallback(async () => {`) - lines.push(` await mizan.whenReady`) - lines.push(` const qs = new URLSearchParams()`) - for (const [pName] of paramEntries) { - lines.push(` if (params.${pName} !== undefined) qs.set('${pName}', String(params.${pName}))`) - } - lines.push(` const resp = await mizan.request('GET', \`\${mizan.baseUrl}/ctx/${ctxName}/?\${qs}\`)`) - lines.push(` const result = await resp.json()`) - lines.push(` setData(result)`) - - // Dependency array: mizan + each param - const deps = ['mizan', ...paramEntries.map(([pName]) => `params.${pName}`)] - lines.push(` }, [${deps.join(', ')}])`) - lines.push('') - lines.push(` useEffect(() => { refetch() }, [refetch])`) - lines.push(` useEffect(() => mizan.registerContextProvider('${ctxName}', refetch), [mizan, refetch])`) - lines.push('') - lines.push(` return <${ctxPascal}ContextInternal value={data}>{children}`) - lines.push(`}`) - lines.push('') - - // Individual data hooks - for (const fn of ctxFunctions) { - const hookPascal = pascalCase(fn.camelName) - lines.push(`export function use${hookPascal}(): ${fn.outputType} {`) - lines.push(` const ctx = useContext(${ctxPascal}ContextInternal)`) - lines.push(` if (!ctx) throw new Error('use${hookPascal} must be used within ${ctxPascal}Context')`) - lines.push(` return ctx.${fn.name}`) - lines.push(`}`) - lines.push('') - } - } - } - - // ============================================================================ - // Mutation Hooks (with auto-invalidation) - // ============================================================================ - - if (mutationFunctions.length > 0) { - lines.push('// ============================================================================') - lines.push('// Mutation Hooks (auto-invalidate on success)') - lines.push('// ============================================================================') - lines.push('') - - for (const fn of mutationFunctions) { - const pascal = pascalCase(fn.camelName) - const transport = fn.transport || 'http' - const affectedContexts = getAffectedContexts(fn.affects) - - lines.push(`/** Call ${fn.name}. Auto-invalidates: ${affectedContexts.join(', ')} */`) - lines.push(`export function use${pascal}() {`) - lines.push(` const mizan = useMizan()`) - - if (fn.hasInput) { - lines.push(` return useCallback(async (input: ${fn.inputType}) => {`) - lines.push(` const result = await mizan.call<${fn.inputType}, ${fn.outputType}>('${fn.name}', input, '${transport}')`) - } else { - lines.push(` return useCallback(async () => {`) - lines.push(` const result = await mizan.call('${fn.name}', undefined, '${transport}')`) - } - - // Invalidation - if (affectedContexts.length === 1) { - lines.push(` await mizan.invalidateContext('${affectedContexts[0]}')`) - } else if (affectedContexts.length > 1) { - lines.push(` await Promise.all([`) - for (const ctx of affectedContexts) { - lines.push(` mizan.invalidateContext('${ctx}'),`) - } - lines.push(` ])`) - } - - lines.push(` return result`) - lines.push(` }, [mizan])`) - lines.push(`}`) - lines.push('') - } - } - - // ============================================================================ - // Plain Function Hooks - // ============================================================================ - - if (plainFunctions.length > 0) { - lines.push('// ============================================================================') - lines.push('// Function Hooks') - lines.push('// ============================================================================') - lines.push('') - - for (const fn of plainFunctions) { - const pascal = pascalCase(fn.camelName) - const transport = fn.transport || 'http' - - if (fn.hasInput) { - lines.push(`/** Call ${fn.name}. Transport: ${transport} */`) - lines.push(`export function use${pascal}() {`) - lines.push(` return useMizanCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`) - lines.push(`}`) - } else { - lines.push(`/** Call ${fn.name}. Transport: ${transport} */`) - lines.push(`export function use${pascal}() {`) - lines.push(` return useMizanCall('${fn.name}', '${transport}')`) - lines.push(`}`) - } - lines.push('') - } - } - - // ============================================================================ - // Re-exports - // ============================================================================ - - lines.push('// ============================================================================') - lines.push('// Re-exports from mizan library') - lines.push('// ============================================================================') - lines.push('') - lines.push("export { useMizan, useMizanStatus, usePush, DjangoError } from 'mizan'") - lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'mizan'") - lines.push('') - - return lines.join('\n') -} - -/** - * Generate server-side hydration helper (runs in Next.js server components). - * This is separate from the client file because it needs to run on the server. - */ -export function generateMizanServer(schema) { - const functions = schema['x-mizan-functions'] || [] - const globalContexts = functions.filter(fn => fn.isContext === 'global') - - if (globalContexts.length === 0) { - return null - } - - // Collect type imports for global contexts - const typeImports = globalContexts.map(ctx => ctx.outputType).filter(Boolean) - const uniqueTypeImports = [...new Set(typeImports)].sort() - - const lines = [ - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '//', - '// Server-side functions for SSR hydration.', - '// These run in Next.js server components/layouts.', - '', - ] - - if (uniqueTypeImports.length > 0) { - lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.mizan'`) - lines.push('') - } - - // Hydration type - lines.push('// ============================================================================') - lines.push('// Hydration Types') - lines.push('// ============================================================================') - lines.push('') - lines.push('/** Typed hydration data for SSR (global contexts only) */') - lines.push('export interface MizanHydrationData {') - for (const ctx of globalContexts) { - lines.push(` ${ctx.camelName}?: ${ctx.outputType}`) - } - lines.push('}') - lines.push('') - lines.push('/** @deprecated Use MizanHydrationData instead */') - lines.push('export type DjangoHydration = MizanHydrationData') - lines.push('') - - // SSR Hydration Helper — single bundled GET - lines.push('// ============================================================================') - lines.push('// SSR Hydration Helper') - lines.push('// ============================================================================') - lines.push('') - lines.push('/**') - lines.push(' * Fetch hydration data for SSR via bundled context endpoint.') - lines.push(' *') - lines.push(' * Call this in your server component:') - lines.push(' * const hydration = await getMizanHydration(client)') - lines.push(' * return ...') - lines.push(' */') - lines.push('export async function getMizanHydration(') - lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise }") - lines.push('): Promise {') - lines.push(' const hydration: MizanHydrationData = {}') - lines.push('') - lines.push(' try {') - lines.push(" const response = await client.request('GET', '/api/mizan/ctx/global/')") - lines.push(' if (response.ok) {') - lines.push(' const result = await response.json()') - for (const ctx of globalContexts) { - lines.push(` if (result?.${ctx.name} !== undefined) hydration.${ctx.camelName} = result.${ctx.name}`) - } - lines.push(' } else {') - lines.push(" console.error('[getMizanHydration] Global context fetch failed:', response.status)") - lines.push(' }') - lines.push(' } catch (e) {') - lines.push(" console.error('[getMizanHydration] Request failed:', e)") - lines.push(' }') - lines.push('') - lines.push(' return hydration') - lines.push('}') - lines.push('') - lines.push('/** @deprecated Use getMizanHydration instead */') - lines.push('export const getDjangoHydration = getMizanHydration') - lines.push('') - - return lines.join('\n') -} - -/** - * Generate all mizan files. - */ -export async function generateMizanFiles(schema, options = {}) { - const types = await generateMizanTypes(schema) - const provider = generateMizanProvider(schema, options) - const server = generateMizanServer(schema) - const forms = generateMizanForms(schema) - - return { types, provider, server, forms } -} - -/** - * Generate typed form hooks with Zod schemas. - */ -export function generateMizanForms(schema) { - const functions = schema['x-mizan-functions'] || [] - - // Group form functions by form name - const formFunctions = functions.filter(fn => fn.isForm) - const formGroups = new Map() - - for (const fn of formFunctions) { - const formName = fn.formName - if (!formGroups.has(formName)) { - formGroups.set(formName, { schema: null, validate: null, submit: null, formset: {} }) - } - const group = formGroups.get(formName) - - if (fn.formRole === 'schema') { - group.schema = fn - group.formFields = fn.formFields || [] - } else if (fn.formRole === 'validate') { - group.validate = fn - } else if (fn.formRole === 'submit') { - group.submit = fn - } else if (fn.formRole === 'formset_schema') { - group.formset.schema = fn - } else if (fn.formRole === 'formset_validate') { - group.formset.validate = fn - } else if (fn.formRole === 'formset_submit') { - group.formset.submit = fn - } - } - - if (formGroups.size === 0) { - return null - } - - const lines = [ - "'use client'", - '', - '// AUTO-GENERATED by mizan - do not edit manually', - '// Regenerate with: npm run schemas', - '', - '// Typed form hooks with Zod validation.', - '// Zod schemas are generated from Django form field definitions.', - '// Client-side validation matches Django constraints (required, max_length, email, etc.)', - '', - "import { z } from 'zod'", - "import {", - " useDjangoFormCore,", - " useDjangoFormsetCore,", - " type DjangoFormState,", - " type DjangoFormsetState,", - " type FormOptions,", - "} from 'mizan'", - '', - '// ============================================================================', - '// Zod Schemas', - '// ============================================================================', - '', - ] - - // Generate Zod schemas for each form - for (const [formName, group] of formGroups) { - if (!group.schema) continue - - const pascalName = toPascalCase(formName) - const schemaName = `${pascalName}Schema` - const fields = group.formFields || [] - - lines.push(`/**`) - lines.push(` * Zod schema for ${formName} form`) - lines.push(` * Generated from Django form field definitions`) - lines.push(` */`) - lines.push(`export const ${schemaName} = z.object({`) - - for (const field of fields) { - const zodField = generateZodField(field) - lines.push(` ${field.name}: ${zodField},`) - } - - lines.push(`})`) - lines.push('') - } - - // Generate TypeScript types from Zod schemas - lines.push('// ============================================================================') - lines.push('// Form Data Types (inferred from Zod schemas)') - lines.push('// ============================================================================') - lines.push('') - - for (const [formName, group] of formGroups) { - if (!group.schema) continue - - const pascalName = toPascalCase(formName) - const schemaName = `${pascalName}Schema` - const typeName = `${pascalName}FormData` - - lines.push(`/** Form data type for ${formName}, inferred from Zod schema */`) - lines.push(`export type ${typeName} = z.infer`) - lines.push('') - } - - lines.push('// ============================================================================') - lines.push('// Form Hooks') - lines.push('// ============================================================================') - lines.push('') - - // Generate hooks for each form - for (const [formName, group] of formGroups) { - if (!group.schema) continue - - const pascalName = toPascalCase(formName) - const hookName = `use${pascalName}Form` - const typeName = `${pascalName}FormData` - const schemaName = `${pascalName}Schema` - - lines.push(`/**`) - lines.push(` * Typed form hook for ${formName}`) - lines.push(` *`) - lines.push(` * Features:`) - lines.push(` * - Full TypeScript inference for form fields`) - lines.push(` * - Client-side Zod validation (instant feedback)`) - lines.push(` * - Server-side Django validation (authoritative)`) - lines.push(` */`) - lines.push(`export function ${hookName}(`) - lines.push(` options?: FormOptions`) - lines.push(`): DjangoFormState<${typeName}> {`) - lines.push(` return useDjangoFormCore<${typeName}>({`) - lines.push(` name: '${formName}',`) - lines.push(` zodSchema: ${schemaName},`) - lines.push(` options,`) - lines.push(` })`) - lines.push(`}`) - lines.push('') - - // Generate formset hook if formset is enabled - if (group.formset.schema) { - const formsetHookName = `use${pascalName}Formset` - - lines.push(`/**`) - lines.push(` * Typed formset hook for ${formName}`) - lines.push(` */`) - lines.push(`export function ${formsetHookName}(`) - lines.push(` initialCount?: number,`) - lines.push(` liveValidation?: boolean`) - lines.push(`): DjangoFormsetState<${typeName}> {`) - lines.push(` return useDjangoFormsetCore<${typeName}>({`) - lines.push(` name: '${formName}',`) - lines.push(` zodSchema: ${schemaName},`) - lines.push(` initialCount,`) - lines.push(` liveValidation,`) - lines.push(` })`) - lines.push(`}`) - lines.push('') - } - } - - // Export list of form names for reference - lines.push('// ============================================================================') - lines.push('// Form Registry') - lines.push('// ============================================================================') - lines.push('') - lines.push('export const MIZAN_FORMS = {') - for (const [formName, group] of formGroups) { - if (!group.schema) continue - const pascalName = toPascalCase(formName) - lines.push(` ${toCamelCase(formName)}: {`) - lines.push(` name: '${formName}',`) - lines.push(` schema: ${pascalName}Schema,`) - lines.push(` hook: 'use${pascalName}Form',`) - lines.push(` hasFormset: ${!!group.formset.schema},`) - lines.push(` },`) - } - lines.push('} as const') - lines.push('') - - return lines.join('\n') -} - -/** - * Generate a Zod field definition from Django field metadata. - */ -function generateZodField(field) { - const { zodType, required, constraints } = field - let zodCode = '' - - // Base type - switch (zodType) { - case 'boolean': - zodCode = 'z.boolean()' - break - case 'number': - zodCode = 'z.number()' - if (constraints.int) { - zodCode += '.int()' - } - break - case 'array': - zodCode = `z.array(z.${constraints.items || 'string'}())` - break - case 'file': - zodCode = 'z.any()' - break - default: - zodCode = 'z.string()' - } - - // Add constraints - if (zodType === 'string') { - if (constraints.email) { - zodCode += ".email('Invalid email address')" - } else if (constraints.url) { - zodCode += ".url('Invalid URL')" - } - - if (constraints.regex) { - const escapedRegex = constraints.regex.replace(/\\/g, '\\\\').replace(/'/g, "\\'") - const message = constraints.regexMessage || 'Invalid format' - zodCode += `.regex(new RegExp('${escapedRegex}'), '${message}')` - } - - if (constraints.min !== undefined) { - zodCode += `.min(${constraints.min})` - } - if (constraints.max !== undefined) { - zodCode += `.max(${constraints.max})` - } - } else if (zodType === 'number') { - if (constraints.min !== undefined) { - zodCode += `.min(${constraints.min})` - } - if (constraints.max !== undefined) { - zodCode += `.max(${constraints.max})` - } - } - - // Handle optional fields - if (!required) { - if (zodType === 'boolean') { - zodCode += '.default(false)' - } else { - zodCode += '.optional()' - } - } - - return zodCode -} - -/** - * Convert form name to PascalCase for type names. - */ -function toPascalCase(str) { - return str - .split(/[.\-_]/) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') -} - -/** - * Convert form name to camelCase for object keys. - */ -function toCamelCase(str) { - const pascal = toPascalCase(str) - return pascal.charAt(0).toLowerCase() + pascal.slice(1) -} - -/** - * Convert camelCase to PascalCase. - */ -function pascalCase(str) { - return str.charAt(0).toUpperCase() + str.slice(1) -} diff --git a/protocol/mizan-generate/generator/lib/stage1.mjs b/protocol/mizan-generate/generator/lib/stage1.mjs deleted file mode 100644 index 248e87f..0000000 --- a/protocol/mizan-generate/generator/lib/stage1.mjs +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Stage 1 Codegen — Framework-agnostic TypeScript output. - * - * Produces: - * types.ts — interfaces from OpenAPI schema - * contexts/.ts — fetchXxxContext(params) per context group - * mutations/.ts — callXxx(args) per mutation - * functions/.ts — callXxx(args) per plain function - * index.ts — re-exports - */ - -import openapiTS, { astToString } from 'openapi-typescript' - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function pascalCase(str) { - return str - .split(/[.\-_]/) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') -} - -function camelCase(str) { - const p = pascalCase(str) - return p.charAt(0).toLowerCase() + p.slice(1) -} - -// TypeScript SyntaxKind values for openapi-typescript AST -const SyntaxKind = { - InterfaceDeclaration: 265, - PropertySignature: 172, - Identifier: 80, -} - -function idName(node) { - return node?.kind === SyntaxKind.Identifier ? node.escapedText : undefined -} - -function getSchemaNamesFromAst(ast) { - if (!Array.isArray(ast)) return [] - const componentsNode = ast.find( - n => n?.kind === SyntaxKind.InterfaceDeclaration && idName(n?.name) === 'components' - ) - if (!componentsNode?.members) return [] - const schemasProp = componentsNode.members.find( - m => m?.kind === SyntaxKind.PropertySignature && idName(m?.name) === 'schemas' && Array.isArray(m?.type?.members) - ) - if (!schemasProp) return [] - return schemasProp.type.members - .map(m => m?.kind === SyntaxKind.PropertySignature ? idName(m.name) : undefined) - .filter(n => typeof n === 'string') -} - -// ─── Types ────────────────────────────────────────────────────────────────── - -export async function generateTypes(schema) { - const ast = await openapiTS(schema) - const schemaNames = getSchemaNamesFromAst(ast) - const typesCode = astToString(ast) - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - typesCode, - '', - '// Convenience type exports', - ...schemaNames.map(name => `export type ${name} = components["schemas"]["${name}"]`), - '', - ] - - return lines.join('\n') -} - -// ─── Context Files ────────────────────────────────────────────────────────── - -export function generateContextFile(ctxName, ctxMeta, functions) { - const pascal = pascalCase(ctxName) - const ctxFunctions = functions.filter(fn => fn.isContext === ctxName) - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - "import { mizanFetch } from '@mizan/base'", - '', - ] - - // Import output types - const typeImports = ctxFunctions.map(fn => fn.outputType).filter(Boolean) - if (typeImports.length > 0) { - lines.push(`import type { ${[...new Set(typeImports)].join(', ')} } from '../types'`) - lines.push('') - } - - // Data interface - lines.push(`export interface ${pascal}ContextData {`) - for (const fn of ctxFunctions) { - lines.push(` ${fn.name}: ${fn.outputType}`) - } - lines.push('}') - lines.push('') - - // Params interface (from x-mizan-contexts) - const params = ctxMeta?.params || {} - const paramEntries = Object.entries(params) - - if (paramEntries.length > 0) { - lines.push(`export interface ${pascal}ContextParams {`) - for (const [pName, pMeta] of paramEntries) { - const tsType = pMeta.type === 'integer' || pMeta.type === 'number' ? 'number' : pMeta.type === 'boolean' ? 'boolean' : 'string' - const optional = pMeta.required ? '' : '?' - lines.push(` ${pName}${optional}: ${tsType}`) - } - lines.push('}') - } else { - lines.push(`export type ${pascal}ContextParams = Record`) - } - lines.push('') - - // Fetch function - lines.push(`export function fetch${pascal}Context(params: ${pascal}ContextParams): Promise<${pascal}ContextData> {`) - lines.push(` return mizanFetch('${ctxName}', params)`) - lines.push('}') - lines.push('') - - return lines.join('\n') -} - -// ─── Mutation Files ───────────────────────────────────────────────────────── - -export function generateMutationFile(fn) { - const pascal = pascalCase(fn.camelName) - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - "import { mizanCall } from '@mizan/base'", - '', - ] - - // Import types - const typeImports = [] - if (fn.hasInput && fn.inputType) typeImports.push(fn.inputType) - if (fn.outputType) typeImports.push(fn.outputType) - if (typeImports.length > 0) { - lines.push(`import type { ${[...new Set(typeImports)].join(', ')} } from '../types'`) - lines.push('') - } - - // Call function - if (fn.hasInput) { - lines.push(`export function call${pascal}(args: ${fn.inputType}): Promise<${fn.outputType}> {`) - } else { - lines.push(`export function call${pascal}(): Promise<${fn.outputType}> {`) - } - lines.push(` return mizanCall('${fn.name}', ${fn.hasInput ? 'args' : '{}'})`) - lines.push('}') - lines.push('') - - return lines.join('\n') -} - -// ─── Function Files (plain, no context, no affects) ───────────────────────── - -export function generateFunctionFile(fn) { - // Same shape as mutation, just different semantics - return generateMutationFile(fn) -} - -// ─── Index ────────────────────────────────────────────────────────────────── - -export function generateStage1Index(schema) { - const functions = schema['x-mizan-functions'] || [] - const contextGroups = schema['x-mizan-contexts'] || {} - - const lines = [ - '// AUTO-GENERATED by mizan — do not edit', - '', - "export * from './types'", - '', - ] - - // Context exports - for (const ctxName of Object.keys(contextGroups)) { - const pascal = pascalCase(ctxName) - lines.push(`export { fetch${pascal}Context, type ${pascal}ContextData, type ${pascal}ContextParams } from './contexts/${ctxName}'`) - } - if (Object.keys(contextGroups).length > 0) lines.push('') - - // Mutation + function exports - const regularFns = functions.filter(fn => !fn.isContext && !fn.isForm) - for (const fn of regularFns) { - const pascal = pascalCase(fn.camelName) - lines.push(`export { call${pascal} } from './${fn.affects ? 'mutations' : 'functions'}/${fn.camelName}'`) - } - if (regularFns.length > 0) lines.push('') - - return lines.join('\n') -} diff --git a/protocol/mizan-generate/package.json b/protocol/mizan-generate/package.json index 9a050b3..8806f46 100644 --- a/protocol/mizan-generate/package.json +++ b/protocol/mizan-generate/package.json @@ -1,14 +1,10 @@ { "name": "mizan-generate", - "version": "1.0.0", - "description": "Mizan codegen — fetches the schema from any backend adapter and emits typed React/Vue/Svelte client code on top of the runtime kernel.", + "version": "2.0.0", + "description": "Mizan codegen — consumes Mizan IR; emits typed React/Vue/Svelte/Rust/Python clients. Ships as a prebuilt Rust binary.", "type": "module", "bin": { - "mizan-generate": "./generator/cli.mjs" + "mizan-generate": "./bin/launcher.mjs" }, - "main": "./generator/cli.mjs", - "license": "MIT", - "dependencies": { - "openapi-typescript": "^7.13.0" - } + "license": "MIT" } diff --git a/tests/afi/afi_codegen_app.py b/tests/afi/afi_codegen_app.py new file mode 100644 index 0000000..a57bcf3 --- /dev/null +++ b/tests/afi/afi_codegen_app.py @@ -0,0 +1,12 @@ +""" +Codegen entrypoint for the AFI fixture. + +`mizan_fastapi.cli` imports a module and runs `build_schema()` from a +populated registry. The fixture's `register_fixture()` is a function +call, not an import side effect; this thin wrapper invokes it on +import so the CLI works without modifying fixture.py's semantics. +""" + +from fixture import register_fixture + +register_fixture() diff --git a/tests/rust/.gitignore b/tests/rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tests/rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/rust/Cargo.lock b/tests/rust/Cargo.lock new file mode 100644 index 0000000..735ebab --- /dev/null +++ b/tests/rust/Cargo.lock @@ -0,0 +1,1595 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixture_client" +version = "0.1.0" +dependencies = [ + "mizan-rust", + "serde", + "serde_json", + "tokio", +] + +[[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-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[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 = "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", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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 = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mizan-rust" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-util", +] + +[[package]] +name = "mizan-rust-wire-parity" +version = "0.0.0" +dependencies = [ + "fixture_client", + "mizan-rust", + "reqwest", + "serde_json", + "tokio", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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_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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[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 0.61.2", +] + +[[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 = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml new file mode 100644 index 0000000..972ff31 --- /dev/null +++ b/tests/rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mizan-rust-wire-parity" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +mizan-rust = { path = "../../frontends/mizan-rust" } +fixture_client = { path = "./fixture_client" } +tokio = { version = "1", features = ["full"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + +[[bin]] +name = "drive_kernel" +path = "src/drive_kernel.rs" + +[[bin]] +name = "drive_emitted" +path = "src/drive_emitted.rs" diff --git a/tests/rust/fixture_client/Cargo.toml b/tests/rust/fixture_client/Cargo.toml new file mode 100644 index 0000000..0b6de81 --- /dev/null +++ b/tests/rust/fixture_client/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "fixture_client" +version = "0.1.0" +edition = "2021" + +[dependencies] +mizan-rust = { path = "../../../frontends/mizan-rust" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt", "macros"] } diff --git a/tests/rust/fixture_client/src/contexts/mod.rs b/tests/rust/fixture_client/src/contexts/mod.rs new file mode 100644 index 0000000..10d1453 --- /dev/null +++ b/tests/rust/fixture_client/src/contexts/mod.rs @@ -0,0 +1,3 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod user; diff --git a/tests/rust/fixture_client/src/contexts/user.rs b/tests/rust/fixture_client/src/contexts/user.rs new file mode 100644 index 0000000..c9d1cdb --- /dev/null +++ b/tests/rust/fixture_client/src/contexts/user.rs @@ -0,0 +1,29 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{UserProfileOutput, UserOrdersOutput}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserContextData { + pub user_profile: UserProfileOutput, + pub user_orders: UserOrdersOutput, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserContextParams { + pub user_id: i64, +} + +pub async fn fetch_user_context( + client: &MizanClient, + params: &UserContextParams, +) -> Result { + let params_value = serde_json::to_value(params).unwrap_or(Value::Object(Default::default())); + let raw = client.fetch_context("user", ¶ms_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode user context: {e}"))) +} diff --git a/tests/rust/fixture_client/src/functions/echo.rs b/tests/rust/fixture_client/src/functions/echo.rs new file mode 100644 index 0000000..86ef70e --- /dev/null +++ b/tests/rust/fixture_client/src/functions/echo.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{EchoOutput, EchoInput}; + +pub async fn call_echo(client: &MizanClient, args: &EchoInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("echo", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode echo result: {e}"))) +} diff --git a/tests/rust/fixture_client/src/functions/find_user.rs b/tests/rust/fixture_client/src/functions/find_user.rs new file mode 100644 index 0000000..3d7f9f0 --- /dev/null +++ b/tests/rust/fixture_client/src/functions/find_user.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{FindUserOutput, FindUserInput}; + +pub async fn call_find_user(client: &MizanClient, args: &FindUserInput) -> Result, MizanError> { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("find_user", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode find_user result: {e}"))) +} diff --git a/tests/rust/fixture_client/src/functions/mod.rs b/tests/rust/fixture_client/src/functions/mod.rs new file mode 100644 index 0000000..e277fc5 --- /dev/null +++ b/tests/rust/fixture_client/src/functions/mod.rs @@ -0,0 +1,6 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod echo; +pub mod find_user; +pub mod rename_user; +pub mod whoami; diff --git a/tests/rust/fixture_client/src/functions/rename_user.rs b/tests/rust/fixture_client/src/functions/rename_user.rs new file mode 100644 index 0000000..cc9820b --- /dev/null +++ b/tests/rust/fixture_client/src/functions/rename_user.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{RenameUserOutput, RenameUserInput}; + +pub async fn call_rename_user(client: &MizanClient, args: &RenameUserInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("rename_user", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode rename_user result: {e}"))) +} diff --git a/tests/rust/fixture_client/src/functions/whoami.rs b/tests/rust/fixture_client/src/functions/whoami.rs new file mode 100644 index 0000000..4512523 --- /dev/null +++ b/tests/rust/fixture_client/src/functions/whoami.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{WhoamiOutput}; + +pub async fn call_whoami(client: &MizanClient) -> Result { + let args_value = Value::Object(Default::default()); + let raw = client.call("whoami", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode whoami result: {e}"))) +} diff --git a/tests/rust/fixture_client/src/lib.rs b/tests/rust/fixture_client/src/lib.rs new file mode 100644 index 0000000..cfc3e80 --- /dev/null +++ b/tests/rust/fixture_client/src/lib.rs @@ -0,0 +1,8 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod types; +pub mod contexts; +pub mod mutations; +pub mod functions; + +pub use mizan_rust::{MizanClient, MizanConfig, MizanError}; diff --git a/tests/rust/fixture_client/src/mutations/mod.rs b/tests/rust/fixture_client/src/mutations/mod.rs new file mode 100644 index 0000000..4410f89 --- /dev/null +++ b/tests/rust/fixture_client/src/mutations/mod.rs @@ -0,0 +1,3 @@ +// AUTO-GENERATED by mizan — do not edit + +pub mod update_profile; diff --git a/tests/rust/fixture_client/src/mutations/update_profile.rs b/tests/rust/fixture_client/src/mutations/update_profile.rs new file mode 100644 index 0000000..819af92 --- /dev/null +++ b/tests/rust/fixture_client/src/mutations/update_profile.rs @@ -0,0 +1,14 @@ +// AUTO-GENERATED by mizan — do not edit + +use serde_json::Value; + +use mizan_rust::{MizanClient, MizanError}; + +use crate::types::{UpdateProfileOutput, UpdateProfileInput}; + +pub async fn call_update_profile(client: &MizanClient, args: &UpdateProfileInput) -> Result { + let args_value = serde_json::to_value(args).unwrap_or(Value::Object(Default::default())); + let raw = client.call("update_profile", args_value).await?; + serde_json::from_value(raw) + .map_err(|e| MizanError::transport(format!("decode update_profile result: {e}"))) +} diff --git a/tests/rust/fixture_client/src/types.rs b/tests/rust/fixture_client/src/types.rs new file mode 100644 index 0000000..7067fdb --- /dev/null +++ b/tests/rust/fixture_client/src/types.rs @@ -0,0 +1,98 @@ +// AUTO-GENERATED by mizan — do not edit + +#![allow(non_camel_case_types)] + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HTTPValidationError { + pub detail: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderOutput { + pub id: i64, + pub user_id: i64, + pub total: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidationError { + pub loc: Vec, + pub msg: String, + #[serde(rename = "type")] + pub r#type: String, + pub input: Option, + pub ctx: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoInput { + pub text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoOutput { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindUserInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindUserOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameUserInput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RenameUserOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProfileInput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateProfileOutput { + pub ok: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserOrdersInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct UserOrdersOutput(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfileInput { + pub user_id: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserProfileOutput { + pub user_id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoamiOutput { + pub email: String, + pub authenticated: bool, +} + diff --git a/tests/rust/mizan.toml b/tests/rust/mizan.toml new file mode 100644 index 0000000..3f72959 --- /dev/null +++ b/tests/rust/mizan.toml @@ -0,0 +1,11 @@ +output = "fixture_client" +targets = ["rust"] +rust_crate_name = "fixture_client" + +[source.fastapi] +module = "afi_codegen_app" +cwd = "../afi" +command = ["uv", "run", "python"] + +[rust_kernel] +path = "../../../frontends/mizan-rust" diff --git a/tests/rust/regen_fixture_client.py b/tests/rust/regen_fixture_client.py new file mode 100644 index 0000000..7629cf7 --- /dev/null +++ b/tests/rust/regen_fixture_client.py @@ -0,0 +1,40 @@ +"""Regenerate the wire-parity fixture_client crate via the Rust codegen binary. + +Drives the Rust `mizan-generate` binary against `tests/rust/mizan.toml`, +which points at the AFI fixture's FastAPI registration module. Output +lands under `tests/rust/fixture_client/` and is consumed by both +`drive_emitted` (typed-emitted-crate probes) and `drive_kernel` +(raw-kernel probes) via the parent `Cargo.toml` workspace. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[1] +BINARY = REPO_ROOT / "protocol/mizan-codegen/target/release/mizan-generate" +CONFIG = HERE / "mizan.toml" + + +def main() -> int: + if not BINARY.exists(): + sys.stderr.write( + f"[regen] binary missing: {BINARY}\n" + "[regen] build it: cargo build --release " + "--manifest-path protocol/mizan-codegen/Cargo.toml\n" + ) + return 1 + + result = subprocess.run( + [str(BINARY), "--config", str(CONFIG)], + cwd=HERE, + ) + return result.returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/rust/run_wire_parity.py b/tests/rust/run_wire_parity.py new file mode 100644 index 0000000..d336242 --- /dev/null +++ b/tests/rust/run_wire_parity.py @@ -0,0 +1,103 @@ +"""Drive the wire-parity check end-to-end. + +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. + +Either non-zero driver exit propagates as the script's exit code. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +AFI_DIR = REPO_ROOT / "tests" / "afi" +RUST_DIR = REPO_ROOT / "tests" / "rust" +BOOT_TIMEOUT_S = 15.0 +POLL_INTERVAL_S = 0.25 + + +def pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_for_server(port: int, timeout_s: float) -> bool: + deadline = time.monotonic() + timeout_s + url = f"http://127.0.0.1:{port}/openapi.json" + 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") + time.sleep(POLL_INTERVAL_S) + return False + + +def run_driver(name: str, base_url: str) -> int: + sys.stdout.write(f"\n=== {name} ===\n") + sys.stdout.flush() + return subprocess.run( + ["cargo", "run", "--quiet", "--bin", name, "--", base_url], + cwd=RUST_DIR, + ).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"], + 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", + ) + 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 + + failures = 0 + for driver in ("drive_kernel", "drive_emitted"): + rc = run_driver(driver, base_url) + if rc != 0: + sys.stderr.write(f"[wire_parity] {driver} exited {rc}\n") + failures += 1 + + return 0 if failures == 0 else 1 + finally: + server.terminate() + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/rust/src/drive_emitted.rs b/tests/rust/src/drive_emitted.rs new file mode 100644 index 0000000..d221cd0 --- /dev/null +++ b/tests/rust/src/drive_emitted.rs @@ -0,0 +1,75 @@ +//! Drive the codegen-emitted `fixture_client` crate against a live +//! FastAPI fixture. Validates not just the kernel wire (which +//! `drive_kernel.rs` already covers) but that the codegen actually +//! produces typed-functions that round-trip cleanly through the same +//! kernel. + +use std::env; +use std::process::ExitCode; + +use mizan_rust::{MizanClient, MizanConfig}; + +use fixture_client::contexts::user::{fetch_user_context, UserContextParams}; +use fixture_client::functions::echo::call_echo; +use fixture_client::functions::find_user::call_find_user; +use fixture_client::functions::rename_user::call_rename_user; +use fixture_client::functions::whoami::call_whoami; +use fixture_client::mutations::update_profile::call_update_profile; +use fixture_client::types::{ + EchoInput, FindUserInput, RenameUserInput, UpdateProfileInput, +}; + + +#[tokio::main] +async fn main() -> ExitCode { + let base_url = env::args().nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8765/api/mizan".to_string()); + let client = MizanClient::new(MizanConfig { + base_url, + session: false, + ..Default::default() + }); + + let mut failures = 0usize; + + match call_echo(&client, &EchoInput { text: "hello".to_string() }).await { + Ok(out) => println!("call_echo -> message={:?}", out.message), + Err(e) => { eprintln!("call_echo ERR {e}"); failures += 1; } + } + + match call_whoami(&client).await { + Ok(out) => println!("call_whoami -> authenticated={} email={:?}", out.authenticated, out.email), + Err(e) => { eprintln!("call_whoami ERR {e}"); failures += 1; } + } + + match call_find_user(&client, &FindUserInput { user_id: 99999 }).await { + Ok(None) => println!("call_find_user(99999) -> None"), + Ok(Some(out)) => println!("call_find_user(99999) -> user_id={} name={:?}", out.user_id, out.name), + Err(e) => { eprintln!("call_find_user ERR {e}"); failures += 1; } + } + + match call_update_profile(&client, &UpdateProfileInput { user_id: 5, name: "Ryth".to_string() }).await { + Ok(out) => println!("call_update_profile -> ok={}", out.ok), + Err(e) => { eprintln!("call_update_profile ERR {e}"); failures += 1; } + } + + match call_rename_user(&client, &RenameUserInput { user_id: 5, name: "RythR".to_string() }).await { + Ok(out) => println!("call_rename_user -> user_id={} name={:?}", out.user_id, out.name), + Err(e) => { eprintln!("call_rename_user ERR {e}"); failures += 1; } + } + + match fetch_user_context(&client, &UserContextParams { user_id: 5 }).await { + Ok(out) => println!( + "fetch_user_context(5) -> user_profile={{ user_id:{}, name:{:?} }} user_orders.len={}", + out.user_profile.user_id, out.user_profile.name, out.user_orders.0.len(), + ), + Err(e) => { eprintln!("fetch_user_context ERR {e}"); failures += 1; } + } + + if failures > 0 { + eprintln!("[drive_emitted] {failures} probe(s) failed"); + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} diff --git a/tests/rust/src/drive_kernel.rs b/tests/rust/src/drive_kernel.rs new file mode 100644 index 0000000..b69dd0b --- /dev/null +++ b/tests/rust/src/drive_kernel.rs @@ -0,0 +1,74 @@ +//! Drive the mizan-rust kernel against a live FastAPI fixture app and +//! print every response. Used by `run_wire_parity.sh` which: +//! +//! 1. Boots `tests/afi/fastapi_app.py` via uvicorn on port 8765. +//! 2. Polls `/openapi.json` until the server is up. +//! 3. Runs `cargo run --bin drive_kernel -- http://127.0.0.1:8765/api/mizan`. +//! 4. Diffs the stdout against a committed snapshot. +//! +//! The kernel exercises every endpoint the fixture declares: the two +//! plain functions (`echo`, `whoami`), the two-function `user` context, +//! the `update_profile` mutation, the `find_user` Optional path, and +//! the `rename_user` merge mutation. + +use std::env; +use std::process::ExitCode; + +use mizan_rust::{MizanClient, MizanConfig}; +use serde_json::{json, Value}; + + +#[tokio::main] +async fn main() -> ExitCode { + let base_url = env::args().nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8765/api/mizan".to_string()); + let client = MizanClient::new(MizanConfig { + base_url, + session: false, + ..Default::default() + }); + + let mut failures = 0usize; + failures += probe(&client, "echo", json!({"text": "hello"})).await; + failures += probe(&client, "whoami", json!({})).await; + failures += probe(&client, "find_user", json!({"user_id": 99999})).await; + failures += probe(&client, "update_profile", json!({"user_id": 5, "name": "Ryth"})).await; + failures += probe(&client, "rename_user", json!({"user_id": 5, "name": "RythR"})).await; + + failures += probe_context(&client, "user", json!({"user_id": 5})).await; + + if failures > 0 { + eprintln!("[drive_kernel] {failures} probe(s) failed"); + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + + +async fn probe(client: &MizanClient, fn_name: &str, args: Value) -> usize { + match client.call(fn_name, args.clone()).await { + Ok(result) => { + println!("call {fn_name} args={args} -> {result}"); + 0 + } + Err(err) => { + eprintln!("call {fn_name} args={args} -> ERR {err}"); + 1 + } + } +} + + +async fn probe_context(client: &MizanClient, name: &str, params: Value) -> usize { + match client.fetch_context(name, ¶ms).await { + Ok(data) => { + println!("ctx {name} params={params} -> {data}"); + 0 + } + Err(err) => { + eprintln!("ctx {name} params={params} -> ERR {err}"); + 1 + } + } +}