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

Adds first-class Rust-backed Mizan to sit alongside mizan-django and
mizan-fastapi. A Rust dev writes:

    #[derive(Mizan, Serialize, Deserialize)]
    pub struct ProfileOutput { pub user_id: i64, pub name: String }

    #[mizan::context("user")]
    pub struct UserCtx;

    #[mizan::client(context = UserCtx)]
    pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput { ... }

…and gets byte-identical KDL to the Python emitters, served over the
same wire protocol the React / Rust / Vue / Svelte kernels speak.

New crates:
- cores/mizan-rust/         (Cargo: mizan-core)     — IR types, KDL emitter, traits, registry,
                                                       runtime (compute_invalidation / compute_merges
                                                       ported from mizan-fastapi), graph_check with
                                                       structural type-matching
- cores/mizan-rust-macros/  (Cargo: mizan-macros)   — #[derive(Mizan)], #[mizan::context],
                                                       #[mizan::client] proc macros
- backends/mizan-rust-axum/ (Cargo: mizan-axum)     — axum HTTP adapter: /session/, /call/, /ctx/:name/
- tests/afi/rust_app/                                — AFI fixture port + server / export-ir binaries

Substrate-shape moves required by cross-language equivalence:
- IR canonicalization: functions / contexts / context-members / shared-by
  now sort alphabetically in both Python and Rust emitters. The IR is a
  contract; linkme doesn't preserve declaration order, so canonical sort
  is the only stable mapping. afi_ir.kdl + per-target baselines regenerated.
- MizanType::TYPE_NAME is a const (with a default type_name() reader) so
  it's usable in linkme TypeEntry static initializers.
- Tree-shaken type registry: #[derive(Mizan)] only emits the trait impl;
  the #[mizan::client] macro registers canonical-named entries from
  fn signatures, including Vec<T> element types for ref resolution.
- Merge resolution is structural (NamedType shape comparison) rather than
  by name — matches the Python types_match_for_merge semantics.

Three-way forcing functions:
- tests/afi/test_codegen_parity.py — Django ≡ FastAPI ≡ Rust on KDL bytes (3 pass)
- tests/rust/run_wire_parity.py    — 12/12 probes against FastAPI + Rust (EXIT=0)

Incidental fixes surfaced by the new tests:
- Stale `from .registry import validate_registry` import removed from
  mizan-django/setup/discovery.py (referenced a function that no longer
  exists; was masking codegen-parity).
- BASE_DIR added to tests/afi/django_app/project/settings.py.
- /session/ endpoint added to mizan-fastapi for protocol-shaped readiness
  probe parity (wire-parity harness now polls /api/mizan/session/ on both
  backends rather than FastAPI's /openapi.json).
- Root .gitignore picks up Rust target/ across the tree so new crates
  don't need per-crate gitignore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:31:26 -04:00
parent 9900f8a36f
commit 45bde51166
47 changed files with 4187 additions and 147 deletions

View File

@@ -469,7 +469,11 @@ def build_ir() -> str:
lines.append("")
# ── Functions ──
for fn_name, fn_class in functions.items():
# Alphabetical by wire name — the IR is a canonical contract, not a
# transcript of registration order. Both Python and Rust emitters sort
# so byte-equivalence holds across language-backed backends.
for fn_name in sorted(functions):
fn_class = functions[fn_name]
meta = getattr(fn_class, "_meta", {})
if meta.get("private") or meta.get("view_path"):
continue
@@ -481,8 +485,9 @@ def build_ir() -> str:
lines.append("")
# ── Contexts ──
for ctx_name, fn_names in context_groups.items():
_emit_context(root, ctx_name, fn_names)
# Alphabetical by context name — same reason as functions above.
for ctx_name in sorted(context_groups):
_emit_context(root, ctx_name, context_groups[ctx_name])
if context_groups:
lines.append("")
@@ -541,14 +546,16 @@ def _emit_context(root: _Block, ctx_name: str, fn_names: list[str]) -> None:
slot["required"] = len(slot["shared_by"]) == len(fn_names)
with root.node("context", _kdl_string(ctx_name)) as block:
for fn_name in fn_names:
# Members alphabetical — canonical order.
for fn_name in sorted(fn_names):
block.leaf("function", _kdl_string(fn_name))
for param_name in sorted(param_info):
slot = param_info[param_name]
with block.node("param", _kdl_string(param_name)) as param_block:
param_block.leaf("type", _kdl_string(slot["type"]))
param_block.leaf("required", _kdl_bool(slot["required"]))
for sharer in slot["shared_by"]:
# `shared-by` follows the same canonical ordering.
for sharer in sorted(slot["shared_by"]):
param_block.leaf("shared-by", _kdl_string(sharer))

View File

@@ -0,0 +1,15 @@
[package]
name = "mizan-macros"
version = "0.1.0"
edition = "2021"
description = "Proc macros for mizan-core: #[derive(Mizan)], #[mizan::context], #[mizan(...)]. Emits MizanType / ContextMarker / FunctionSpec impls plus linkme registrations."
license = "MIT"
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full", "extra-traits"] }
heck = "0.5"

View File

@@ -0,0 +1,77 @@
//! `#[mizan::context]` / `#[mizan::context("name")]` — emit `ContextMarker`
//! impl + linkme registration for a unit struct.
use heck::ToSnakeCase;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{parse::Parser, punctuated::Punctuated, ItemStruct, Lit, LitStr, Meta, Token};
/// Attribute args: either nothing, or one string literal that overrides the
/// derived snake_case context name.
pub struct ContextArgs {
pub explicit_name: Option<String>,
}
impl ContextArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self { explicit_name: None });
}
// Support both `#[mizan::context("user")]` (string literal) and
// `#[mizan::context(name = "user")]` (key=value).
if let Ok(lit) = syn::parse2::<LitStr>(attr_tokens.clone()) {
return Ok(Self {
explicit_name: Some(lit.value()),
});
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
for meta in metas {
if let Meta::NameValue(nv) = meta {
if nv.path.is_ident("name") {
if let syn::Expr::Lit(syn::ExprLit { lit: Lit::Str(s), .. }) = nv.value {
return Ok(Self {
explicit_name: Some(s.value()),
});
}
}
}
}
Err(syn::Error::new_spanned(
"",
"expected `#[mizan::context]` or `#[mizan::context(\"<name>\")]` or `#[mizan::context(name = \"<name>\")]`",
))
}
}
pub fn expand(args: ContextArgs, item: ItemStruct) -> TokenStream {
if !item.fields.is_empty() {
return syn::Error::new_spanned(
&item.fields,
"#[mizan::context] requires a unit struct — context markers carry no data.",
)
.to_compile_error();
}
let ident = item.ident.clone();
let name = args
.explicit_name
.unwrap_or_else(|| ident.to_string().to_snake_case());
let register_static =
format_ident!("__MIZAN_CTX_REGISTER_{}", ident.to_string().to_uppercase());
quote! {
#item
impl ::mizan_core::ContextMarker for #ident {
const NAME: &'static str = #name;
}
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::CONTEXTS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: ::mizan_core::ContextEntry = ::mizan_core::ContextEntry {
name: #name,
};
}
}

View File

@@ -0,0 +1,102 @@
//! `#[derive(Mizan)]` — emit `MizanType` impl + linkme registration.
use proc_macro2::TokenStream;
use quote::quote;
use syn::{Data, DataEnum, DataStruct, DeriveInput, Fields};
use crate::shape::type_shape_expr;
/// Expand `#[derive(Mizan)]`. Emits only the `MizanType` trait impl —
/// registration into the IR `TYPES` slice happens at the function macro
/// (which owns the canonical-named type entries `<camelName>Input` /
/// `<camelName>Output`) and at sub-type discovery inside `Vec<T>` outputs.
/// This keeps the type registry tree-shaken: only types actually reachable
/// from a registered function appear in the emitted IR.
pub fn expand(input: DeriveInput) -> TokenStream {
let ident = input.ident.clone();
let type_name = ident.to_string();
let named_type_body = match &input.data {
Data::Struct(s) => emit_struct(s),
Data::Enum(e) => emit_enum(e),
Data::Union(_) => {
return syn::Error::new_spanned(
&input,
"#[derive(Mizan)] does not support `union` types — use a struct or enum.",
)
.to_compile_error();
}
};
quote! {
impl ::mizan_core::MizanType for #ident {
const TYPE_NAME: &'static str = #type_name;
fn shape() -> ::mizan_core::NamedType { #named_type_body }
}
}
}
fn emit_struct(s: &DataStruct) -> TokenStream {
let fields = match &s.fields {
Fields::Named(named) => &named.named,
Fields::Unnamed(_) | Fields::Unit => {
return syn::Error::new_spanned(
&s.fields,
"#[derive(Mizan)] requires named fields. Tuple structs and unit structs aren't part of the IR shape.",
)
.to_compile_error();
}
};
let mut field_exprs: Vec<TokenStream> = Vec::new();
for field in fields {
let ident = field
.ident
.as_ref()
.expect("named field always has an ident");
let name = ident.to_string();
let shape = type_shape_expr(&field.ty);
// A field is `required` iff its type is not `Option<...>`. Defaults
// are not encodable from Rust syntax (no `= expr` on a struct field
// declaration) — the macro emits `required: false, default: None`
// for Option-wrapped fields, leaving defaults for a future
// attribute-based extension.
let is_optional = crate::shape::unwrap_option(&field.ty).is_some();
let required = !is_optional;
field_exprs.push(quote! {
::mizan_core::StructField {
name: #name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_exprs),*
])
}
}
fn emit_enum(e: &DataEnum) -> TokenStream {
let mut variants: Vec<TokenStream> = Vec::new();
for variant in &e.variants {
if !matches!(variant.fields, Fields::Unit) {
return syn::Error::new_spanned(
&variant.fields,
"#[derive(Mizan)] only supports unit-variant enums (string-literal enums in the IR). Variants with payload aren't expressible in the current IR.",
)
.to_compile_error();
}
let name = variant.ident.to_string();
variants.push(quote! { #name });
}
quote! {
::mizan_core::NamedType::Enum(::std::vec![
#(#variants),*
])
}
}

View File

@@ -0,0 +1,494 @@
//! `#[mizan(...)]` — on async fns. Generates:
//! * a synthetic Input struct (`<camelName>Input`) when the fn has params
//! * `MizanType` impl on the Input struct
//! * canonical type entries (`<camelName>Input` / `<camelName>Output`)
//! * Vec-element sub-type entries (so `Vec<T>` outputs surface `T` too)
//! * `FunctionSpec` impl on a ZST `__MizanFn_<name>`
//! * `FUNCTIONS` linkme registration of `&__MIZAN_FN_<NAME>_INSTANCE`
use heck::{ToLowerCamelCase, ToShoutySnakeCase};
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{
parse::Parser,
punctuated::Punctuated,
spanned::Spanned,
Expr, ExprPath, ExprTuple, FnArg, ItemFn, Meta, Pat, Path, ReturnType, Token, Type,
};
use crate::shape::{analyze_return, primitive_of, type_shape_expr, unwrap_option};
/// Parsed attribute args for `#[mizan(...)]`.
#[derive(Default)]
pub struct FunctionArgs {
pub context: Option<Path>,
pub affects: Vec<Path>,
pub merge: Vec<Path>,
pub websocket: bool,
pub private: bool,
}
impl FunctionArgs {
pub fn parse(attr_tokens: TokenStream) -> syn::Result<Self> {
if attr_tokens.is_empty() {
return Ok(Self::default());
}
let parser = Punctuated::<Meta, Token![,]>::parse_terminated;
let metas = parser.parse2(attr_tokens)?;
let mut out = Self::default();
for meta in metas {
match meta {
Meta::NameValue(nv) => {
if nv.path.is_ident("context") {
out.context = Some(expect_path(&nv.value)?);
} else if nv.path.is_ident("affects") {
out.affects = collect_paths(&nv.value)?;
} else if nv.path.is_ident("merge") {
out.merge = collect_paths(&nv.value)?;
} else {
return Err(syn::Error::new_spanned(
nv.path,
"unknown attribute key; expected one of: context, affects, merge",
));
}
}
Meta::Path(p) => {
if p.is_ident("websocket") {
out.websocket = true;
} else if p.is_ident("private") {
out.private = true;
} else {
return Err(syn::Error::new_spanned(
p,
"unknown flag; expected `websocket` or `private`",
));
}
}
Meta::List(l) => {
return Err(syn::Error::new_spanned(
l,
"list-shaped attribute args not supported here",
));
}
}
}
if out.context.is_some() && !out.affects.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `affects` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
if out.context.is_some() && !out.merge.is_empty() {
return Err(syn::Error::new_spanned(
out.context.as_ref().unwrap(),
"`context` and `merge` are mutually exclusive — a function is either a context reader or a mutation.",
));
}
Ok(out)
}
}
fn expect_path(expr: &Expr) -> syn::Result<Path> {
if let Expr::Path(ExprPath { path, .. }) = expr {
Ok(path.clone())
} else {
Err(syn::Error::new_spanned(
expr,
"expected a type path (e.g. `UserCtx`)",
))
}
}
fn collect_paths(expr: &Expr) -> syn::Result<Vec<Path>> {
match expr {
Expr::Path(_) => Ok(vec![expect_path(expr)?]),
Expr::Tuple(ExprTuple { elems, .. }) => elems.iter().map(expect_path).collect(),
_ => Err(syn::Error::new_spanned(
expr,
"expected a context type or a tuple of context types (e.g. `UserCtx` or `(UserCtx, OrderCtx)`)",
)),
}
}
/// Information about one input parameter, extracted from the fn signature.
struct InputArg {
ident: syn::Ident,
ty: Type,
}
pub fn expand(args: FunctionArgs, item: ItemFn) -> TokenStream {
if item.sig.asyncness.is_none() {
return syn::Error::new_spanned(
&item.sig.fn_token,
"#[mizan] requires an `async fn`. Wrap synchronous handlers if needed.",
)
.to_compile_error();
}
let fn_name = item.sig.ident.to_string();
let camel = fn_name.to_lower_camel_case();
let input_type_name = format!("{camel}Input");
let output_type_name = format!("{camel}Output");
let input_args = match collect_input_args(&item) {
Ok(v) => v,
Err(e) => return e.to_compile_error(),
};
let has_input = !input_args.is_empty();
let input_type_ident = format_ident!("{}", input_type_name);
let return_ty = match &item.sig.output {
ReturnType::Type(_, t) => (**t).clone(),
ReturnType::Default => {
return syn::Error::new_spanned(
&item.sig,
"#[mizan] requires an explicit return type. Add `-> T` to the signature.",
)
.to_compile_error();
}
};
let analysis = analyze_return(&return_ty);
// ─── Synthetic Input struct ────────────────────────────────────────────
let input_struct = if has_input {
let mut field_defs = Vec::new();
let mut field_shapes = Vec::new();
for arg in &input_args {
let ident = &arg.ident;
let ty = &arg.ty;
// Strip a leading underscore from the wire-level field name —
// Rust convention uses `_foo` to silence unused-arg warnings,
// but the wire schema and the Python fixture name the param
// `foo`. The struct field keeps its source ident (so the
// dispatch wrapper's `validated.#ident` compiles), and a serde
// `rename` bridges the wire-level JSON name.
let name_str = ident.to_string();
let wire_name = name_str.trim_start_matches('_').to_string();
let serde_rename = if wire_name != name_str {
quote! { #[serde(rename = #wire_name)] }
} else {
TokenStream::new()
};
field_defs.push(quote! { #serde_rename pub #ident: #ty, });
let is_optional = unwrap_option(ty).is_some();
let required = !is_optional;
let shape = type_shape_expr(ty);
field_shapes.push(quote! {
::mizan_core::StructField {
name: #wire_name,
required: #required,
default: ::std::option::Option::None,
shape: #shape,
}
});
}
quote! {
#[derive(::std::fmt::Debug, ::std::clone::Clone, ::serde::Serialize, ::serde::Deserialize)]
pub struct #input_type_ident {
#(#field_defs)*
}
impl ::mizan_core::MizanType for #input_type_ident {
const TYPE_NAME: &'static str = #input_type_name;
fn shape() -> ::mizan_core::NamedType {
::mizan_core::NamedType::Struct(::std::vec![
#(#field_shapes),*
])
}
}
}
} else {
TokenStream::new()
};
// ─── Type entry registrations ──────────────────────────────────────────
// - Input: TypeEntry pointing at the synthetic input struct's shape_fn.
// - Output: TypeEntry whose shape is a copy of the user's Output shape
// (for struct outputs) or an `Alias(List(Ref("T")))` (for Vec outputs).
// - For Vec<T> outputs, ALSO register T's TypeEntry pointing at T's
// MizanType impl (so the Ref resolves in the IR).
let mut type_registrations = Vec::new();
if has_input {
let static_ident =
format_ident!("__MIZAN_TYPE_{}", input_type_name.to_shouty_snake_case());
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #static_ident: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #input_type_name,
shape_fn: <#input_type_ident as ::mizan_core::MizanType>::shape,
};
});
}
let output_static = format_ident!("__MIZAN_TYPE_{}", output_type_name.to_shouty_snake_case());
if analysis.is_vec {
let elem = analysis.vec_inner.as_ref().expect("vec_inner set");
// userOrdersOutput → alias { list { ref "OrderOutput" } }
// The Ref name is resolved via `<T as MizanType>::type_name()`.
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: || ::mizan_core::NamedType::Alias(
::mizan_core::TypeShape::List(::std::boxed::Box::new(
::mizan_core::TypeShape::Ref(<#elem as ::mizan_core::MizanType>::TYPE_NAME)
))
),
};
});
// Also register the element type itself by its own name. `TYPE_NAME`
// is an associated const, so this is usable in a static initializer.
let elem_static = element_type_static_ident(elem);
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #elem_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: <#elem as ::mizan_core::MizanType>::TYPE_NAME,
shape_fn: <#elem as ::mizan_core::MizanType>::shape,
};
});
} else {
// Non-Vec output: copy the inner type's shape under the canonical name.
let inner_ty = &analysis.inner;
type_registrations.push(quote! {
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::TYPES)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #output_static: ::mizan_core::TypeEntry = ::mizan_core::TypeEntry {
name: #output_type_name,
shape_fn: <#inner_ty as ::mizan_core::MizanType>::shape,
};
});
}
// ─── InputParam slice (for context-builder shared-param elevation) ────
let mut input_params = Vec::new();
for arg in &input_args {
// Wire-level name strips the underscore prefix — see input_struct
// above for the rationale.
let name_str = arg.ident.to_string();
let name_str = name_str.trim_start_matches('_').to_string();
let primitive = primitive_of(&arg.ty).unwrap_or_else(|| {
// Non-primitive params don't surface in the context's `param`
// block; they participate as opaque payloads. Using `String` as
// the placeholder primitive matches Python's fallback in
// `_annotation_to_primitive`.
quote! { ::mizan_core::Primitive::String }
});
let is_optional = unwrap_option(&arg.ty).is_some();
let required = !is_optional;
input_params.push(quote! {
::mizan_core::InputParam {
name: #name_str,
primitive: #primitive,
required: #required,
}
});
}
let params_static = format_ident!("__MIZAN_FN_{}_PARAMS", fn_name.to_shouty_snake_case());
let params_const = quote! {
const #params_static: &[::mizan_core::InputParam] = &[
#(#input_params),*
];
};
// ─── AffectTarget / merge / context wiring ─────────────────────────────
let affects_static = format_ident!("__MIZAN_FN_{}_AFFECTS", fn_name.to_shouty_snake_case());
let affects_entries: Vec<_> = args
.affects
.iter()
.map(|p| {
quote! {
::mizan_core::AffectTarget::Context(<#p as ::mizan_core::ContextMarker>::NAME)
}
})
.collect();
let affects_const = quote! {
const #affects_static: &[::mizan_core::AffectTarget] = &[
#(#affects_entries),*
];
};
let merge_static = format_ident!("__MIZAN_FN_{}_MERGE", fn_name.to_shouty_snake_case());
let merge_entries: Vec<_> = args
.merge
.iter()
.map(|p| quote! { <#p as ::mizan_core::ContextMarker>::NAME })
.collect();
let merge_const = quote! {
const #merge_static: &[&'static str] = &[
#(#merge_entries),*
];
};
let context_value = match &args.context {
Some(p) => quote! { ::std::option::Option::Some(<#p as ::mizan_core::ContextMarker>::NAME) },
None => quote! { ::std::option::Option::None },
};
let transport_value = if args.websocket {
quote! { ::mizan_core::Transport::Websocket }
} else {
quote! { ::mizan_core::Transport::Http }
};
// ─── Dispatch wrapper + FunctionSpec impl ──────────────────────────────
let inner_fn_ident = item.sig.ident.clone();
let spec_struct = format_ident!("__MizanFn_{}", inner_fn_ident);
let spec_const = format_ident!("__MIZAN_FN_{}_SPEC", fn_name.to_shouty_snake_case());
let register_static =
format_ident!("__MIZAN_FN_{}_REGISTER", fn_name.to_shouty_snake_case());
let input_type_opt = if has_input {
quote! { ::std::option::Option::Some(#input_type_name) }
} else {
quote! { ::std::option::Option::None }
};
let output_nullable = analysis.nullable;
let private = args.private;
let dispatch_body = build_dispatch(&item, &input_args, has_input, &input_type_ident);
quote! {
// Keep the user's original fn intact — the macro never rewrites the
// body, only wraps it for dispatch.
#item
#input_struct
#(#type_registrations)*
#params_const
#affects_const
#merge_const
#[allow(non_camel_case_types)]
pub struct #spec_struct;
impl ::mizan_core::FunctionSpec for #spec_struct {
fn name(&self) -> &'static str { #fn_name }
fn camel_name(&self) -> &'static str { #camel }
fn has_input(&self) -> bool { #has_input }
fn input_type(&self) -> ::std::option::Option<&'static str> { #input_type_opt }
fn output_type(&self) -> &'static str { #output_type_name }
fn output_nullable(&self) -> bool { #output_nullable }
fn context(&self) -> ::std::option::Option<&'static str> { #context_value }
fn affects(&self) -> &'static [::mizan_core::AffectTarget] { #affects_static }
fn merge(&self) -> &'static [&'static str] { #merge_static }
fn transport(&self) -> ::mizan_core::Transport { #transport_value }
fn private(&self) -> bool { #private }
fn input_params(&self) -> &'static [::mizan_core::InputParam] { #params_static }
fn dispatch<'a>(
&'a self,
req: ::mizan_core::RequestHandle<'a>,
args: ::mizan_core::__priv::serde_json::Value,
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<
Output = ::std::result::Result<
::mizan_core::__priv::serde_json::Value,
::mizan_core::MizanError,
>
> + ::std::marker::Send + 'a>> {
::std::boxed::Box::pin(async move {
#dispatch_body
})
}
}
#[allow(non_upper_case_globals)]
static #spec_const: #spec_struct = #spec_struct;
#[::mizan_core::__priv::linkme::distributed_slice(::mizan_core::FUNCTIONS)]
#[linkme(crate = ::mizan_core::__priv::linkme)]
static #register_static: &dyn ::mizan_core::FunctionSpec = &#spec_const;
}
}
fn collect_input_args(item: &ItemFn) -> syn::Result<Vec<InputArg>> {
let mut out = Vec::new();
let mut iter = item.sig.inputs.iter();
// First arg is the request handle — skip without inspection. The function
// body uses it directly; the dispatch wrapper forwards `req`.
if iter.next().is_none() {
return Err(syn::Error::new(
item.sig.span(),
"#[mizan] functions must accept at least a request handle as the first parameter (e.g. `&Request` or `RequestHandle`).",
));
}
for arg in iter {
match arg {
FnArg::Typed(pat) => {
let ident = match &*pat.pat {
Pat::Ident(pi) => pi.ident.clone(),
_ => {
return Err(syn::Error::new_spanned(
&pat.pat,
"#[mizan] function parameters must be plain identifiers (no destructuring).",
));
}
};
out.push(InputArg {
ident,
ty: (*pat.ty).clone(),
});
}
FnArg::Receiver(_) => {
return Err(syn::Error::new_spanned(
arg,
"#[mizan] functions are free functions, not methods. `self` is not allowed.",
));
}
}
}
Ok(out)
}
fn build_dispatch(
item: &ItemFn,
input_args: &[InputArg],
has_input: bool,
input_type_ident: &syn::Ident,
) -> TokenStream {
let inner = &item.sig.ident;
if has_input {
let arg_names: Vec<_> = input_args.iter().map(|a| &a.ident).collect();
quote! {
let validated: #input_type_ident = ::mizan_core::__priv::serde_json::from_value(args)
.map_err(|e| ::mizan_core::MizanError::ValidationFailed {
message: format!("input validation failed: {e}"),
details: ::mizan_core::__priv::serde_json::Value::Null,
})?;
let result = #inner(
&req,
#( validated.#arg_names ),*
).await;
::mizan_core::__priv::serde_json::to_value(&result)
.map_err(|e| ::mizan_core::MizanError::InternalError(
format!("output serialization failed: {e}"),
))
}
} else {
quote! {
let _ = args;
let result = #inner(&req).await;
::mizan_core::__priv::serde_json::to_value(&result)
.map_err(|e| ::mizan_core::MizanError::InternalError(
format!("output serialization failed: {e}"),
))
}
}
}
fn element_type_static_ident(ty: &Type) -> syn::Ident {
// Derive a unique static-name for the type's registration entry. Uses
// the last path segment's identifier as the discriminator.
let last = match ty {
Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
};
let suffix = last.unwrap_or_else(|| "ANON".to_string()).to_shouty_snake_case();
format_ident!("__MIZAN_TYPE_ELEM_{}", suffix)
}

View File

@@ -0,0 +1,58 @@
//! Proc macros for `mizan-core`. See sibling modules for each macro's body.
//!
//! Consumer code reads:
//! ```ignore
//! use mizan_core::prelude::*;
//! pub use mizan_core as mizan; // so `#[mizan::context]` / `#[mizan::client]` read naturally
//!
//! #[derive(Mizan, serde::Serialize, serde::Deserialize)]
//! pub struct ProfileOutput { pub user_id: i64, pub name: String }
//!
//! #[mizan::context("user")]
//! pub struct UserCtx;
//!
//! #[mizan::client(context = UserCtx)]
//! pub async fn user_profile(req: &Request, user_id: i64) -> ProfileOutput { ... }
//! ```
//!
//! The function macro is named `client` to mirror Python's `@client`
//! decorator and to keep the namespace `mizan::` purely a module path —
//! `#[mizan(...)]` would collide with `mizan::context` (a module path
//! can't simultaneously be a callable macro in Rust).
mod context;
mod derive;
mod function;
mod shape;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput, ItemFn, ItemStruct};
#[proc_macro_derive(Mizan)]
pub fn derive_mizan(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
derive::expand(input).into()
}
#[proc_macro_attribute]
pub fn context(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match context::ContextArgs::parse(attr.into()) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemStruct);
context::expand(args, item).into()
}
/// The function-registration attribute macro. Used as `#[mizan::client]`
/// (no args) or `#[mizan::client(context = X, affects = Y, merge = Z,
/// websocket, private)]`.
#[proc_macro_attribute]
pub fn client(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = match function::FunctionArgs::parse(attr.into()) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
let item = parse_macro_input!(item as ItemFn);
function::expand(args, item).into()
}

View File

@@ -0,0 +1,123 @@
//! Lower a `syn::Type` to a TypeShape construction expression. Shared by
//! `#[derive(Mizan)]` (for struct fields) and `#[mizan(...)]` (for fn input
//! params + return-type analysis).
use proc_macro2::TokenStream;
use quote::quote;
use syn::{GenericArgument, PathArguments, Type, TypePath};
/// Result of inspecting a fn's return type.
pub struct ReturnAnalysis {
/// Inner type once `Option<...>` is unwrapped.
pub inner: Type,
/// True if the outermost wrapper is `Option<...>`.
pub nullable: bool,
/// True if `inner` is `Vec<T>` — caller emits an alias type entry.
pub is_vec: bool,
/// When `is_vec`, this is the element type `T`.
pub vec_inner: Option<Type>,
}
pub fn analyze_return(ty: &Type) -> ReturnAnalysis {
let (inner, nullable) = if let Some(t) = unwrap_option(ty) {
(t, true)
} else {
(ty.clone(), false)
};
if let Some(elem) = unwrap_vec(&inner) {
ReturnAnalysis {
inner: inner.clone(),
nullable,
is_vec: true,
vec_inner: Some(elem),
}
} else {
ReturnAnalysis {
inner,
nullable,
is_vec: false,
vec_inner: None,
}
}
}
/// Emit a `TypeShape` const-expression for `ty`. Used inside `#[derive(Mizan)]`
/// when constructing the struct field shapes.
pub fn type_shape_expr(ty: &Type) -> TokenStream {
if let Some(inner) = unwrap_option(ty) {
let inner_shape = type_shape_expr(&inner);
return quote! {
::mizan_core::TypeShape::Optional(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(elem) = unwrap_vec(ty) {
let inner_shape = type_shape_expr(&elem);
return quote! {
::mizan_core::TypeShape::List(::std::boxed::Box::new(#inner_shape))
};
}
if let Some(p) = primitive_of(ty) {
return quote! { ::mizan_core::TypeShape::Primitive(#p) };
}
// Fallback: assume a user-defined struct/enum implementing MizanType.
// The Ref name comes from `<T as MizanType>::type_name()` at runtime.
quote! { ::mizan_core::TypeShape::Ref(<#ty as ::mizan_core::MizanType>::type_name()) }
}
/// Emit a `Primitive` const-expression for `ty`, or `None` if `ty` isn't a
/// known primitive scalar.
pub fn primitive_of(ty: &Type) -> Option<TokenStream> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
match name.as_str() {
"i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" | "u128"
| "usize" => Some(quote! { ::mizan_core::Primitive::Integer }),
"f32" | "f64" => Some(quote! { ::mizan_core::Primitive::Number }),
"bool" => Some(quote! { ::mizan_core::Primitive::Boolean }),
"String" | "str" => Some(quote! { ::mizan_core::Primitive::String }),
_ => None,
}
}
/// If `ty` is `Option<T>`, return `T`. Otherwise None.
pub fn unwrap_option(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Option" {
return None;
}
extract_single_generic(&last.arguments)
}
/// If `ty` is `Vec<T>`, return `T`. Otherwise None.
pub fn unwrap_vec(ty: &Type) -> Option<Type> {
let path = match ty {
Type::Path(TypePath { qself: None, path }) => path,
_ => return None,
};
let last = path.segments.last()?;
if last.ident != "Vec" {
return None;
}
extract_single_generic(&last.arguments)
}
fn extract_single_generic(args: &PathArguments) -> Option<Type> {
let args = match args {
PathArguments::AngleBracketed(a) => a,
_ => return None,
};
for arg in &args.args {
if let GenericArgument::Type(t) = arg {
return Some(t.clone());
}
}
None
}

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

@@ -0,0 +1,173 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "linkme"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf"
dependencies = [
"linkme-impl",
]
[[package]]
name = "linkme-impl"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"indoc",
"linkme",
"mizan-macros",
"serde",
"serde_json",
]
[[package]]
name = "mizan-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -0,0 +1,16 @@
[package]
name = "mizan-core"
version = "0.1.0"
edition = "2021"
description = "Mizan server-side IR substrate — types, traits, KDL emitter, registry. Rust analog of cores/mizan-python/src/mizan_core/."
license = "MIT"
[dependencies]
linkme = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
mizan-macros = { path = "../mizan-rust-macros" }
[dev-dependencies]
indoc = "2"

View File

@@ -0,0 +1,165 @@
//! Cross-function invariant verification — fails at `build_ir()` time, which
//! runs at the codegen subprocess (`cargo run --bin export-ir`). All
//! graph-level inconsistencies surface before any client artifact is emitted.
use crate::ir::{AffectTarget, NamedType, StructField, TypeShape};
use crate::registry::{lookup_context, CONTEXTS, FUNCTIONS, TYPES};
/// Walk the registered types and find the named type's shape. Used by both
/// graph-check and runtime merge resolution.
pub(crate) fn resolve_type_shape(name: &str) -> Option<NamedType> {
for entry in TYPES {
if entry.name == name {
return Some((entry.shape_fn)());
}
}
None
}
/// Structural equality on named types. Two types are merge-compatible iff
/// they have identical shape — matches Python's `types_match_for_merge`.
pub(crate) fn types_match(a: &NamedType, b: &NamedType) -> bool {
match (a, b) {
(NamedType::Struct(fa), NamedType::Struct(fb)) => fields_match(fa, fb),
(NamedType::Alias(sa), NamedType::Alias(sb)) => shapes_match(sa, sb),
(NamedType::Enum(va), NamedType::Enum(vb)) => va == vb,
_ => false,
}
}
fn fields_match(a: &[StructField], b: &[StructField]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(fa, fb)| {
fa.name == fb.name && fa.required == fb.required && shapes_match(&fa.shape, &fb.shape)
})
}
fn shapes_match(a: &TypeShape, b: &TypeShape) -> bool {
match (a, b) {
(TypeShape::Primitive(pa), TypeShape::Primitive(pb)) => {
std::mem::discriminant(pa) == std::mem::discriminant(pb)
}
(TypeShape::Ref(na), TypeShape::Ref(nb)) => {
// Refs match iff the named types they reference match.
match (resolve_type_shape(na), resolve_type_shape(nb)) {
(Some(ta), Some(tb)) => types_match(&ta, &tb),
_ => na == nb,
}
}
(TypeShape::List(ia), TypeShape::List(ib)) => shapes_match(ia, ib),
(TypeShape::Optional(ia), TypeShape::Optional(ib)) => shapes_match(ia, ib),
(TypeShape::Enum(va), TypeShape::Enum(vb)) => va == vb,
(TypeShape::Union(ba), TypeShape::Union(bb)) => {
ba.len() == bb.len() && ba.iter().zip(bb.iter()).all(|(x, y)| shapes_match(x, y))
}
_ => false,
}
}
/// Panic with a structured message if the registered function graph is
/// inconsistent. Called from `build_ir()`.
pub fn verify_invariants() {
check_affects_targets();
check_merge_targets();
check_shared_param_types();
}
fn check_affects_targets() {
for fn_spec in FUNCTIONS {
for affect in fn_spec.affects() {
if let AffectTarget::Context(name) = affect {
if lookup_context(name).is_none() {
panic!(
"Mizan graph-check: function `{}` declares `affects = \"{}\"` but no context with that name is registered. \
Either register a context with that name (via `#[mizan::context(\"{}\")]`) or remove the affects target.",
fn_spec.name(),
name,
name,
);
}
}
}
}
}
fn check_merge_targets() {
for fn_spec in FUNCTIONS {
for merge_target in fn_spec.merge() {
let ctx_entry = match lookup_context(merge_target) {
Some(c) => c,
None => panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no context with that name is registered.",
fn_spec.name(),
merge_target,
),
};
let mutation_output = fn_spec.output_type();
let mutation_shape = match resolve_type_shape(mutation_output) {
Some(s) => s,
None => panic!(
"Mizan graph-check: function `{}` has output type `{}` but no such named type is registered.",
fn_spec.name(), mutation_output,
),
};
let mut matches: Vec<&'static str> = Vec::new();
for candidate in FUNCTIONS {
if candidate.context() != Some(ctx_entry.name) {
continue;
}
if let Some(candidate_shape) = resolve_type_shape(candidate.output_type()) {
if types_match(&candidate_shape, &mutation_shape) {
matches.push(candidate.name());
}
}
}
if matches.is_empty() {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but no member of that context has output type `{}`. \
Add a context member returning `{}`, or remove the merge declaration in favor of `affects` for plain refetch.",
fn_spec.name(), merge_target, mutation_output, mutation_output,
);
}
if matches.len() > 1 {
panic!(
"Mizan graph-check: function `{}` declares `merge = \"{}\"` but multiple members ({}) share output type `{}`. \
Merge resolution requires exactly one match. Distinguish the outputs or use `affects` for refetch.",
fn_spec.name(), merge_target, matches.join(", "), mutation_output,
);
}
}
}
}
fn check_shared_param_types() {
for ctx in CONTEXTS {
let mut by_name: std::collections::HashMap<&'static str, (crate::ir::Primitive, &'static str)>
= std::collections::HashMap::new();
for fn_spec in FUNCTIONS {
if fn_spec.context() != Some(ctx.name) {
continue;
}
for p in fn_spec.input_params() {
if let Some((prev_primitive, prev_fn)) = by_name.get(p.name) {
if std::mem::discriminant(prev_primitive)
!= std::mem::discriminant(&p.primitive)
{
panic!(
"Mizan graph-check: context `{}` has a parameter `{}` whose type diverges across members. \
Function `{}` declares it as `{}`, function `{}` declares it as `{}`. \
Shared params must have one type across the whole context.",
ctx.name, p.name,
prev_fn, prev_primitive.name(),
fn_spec.name(), p.primitive.name(),
);
}
} else {
by_name.insert(p.name, (p.primitive, fn_spec.name()));
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
//! IR data model — mirrors `cores/mizan-python/src/mizan_core/ir.py` 1:1.
//!
//! The IR is the contract. Backends emit it; codegen consumes it. The Rust
//! side produces byte-equivalent KDL to the Python emitter against the same
//! function registry.
/// A named type that appears in the IR's `type "<Name>" { ... }` section.
#[derive(Debug, Clone)]
pub enum NamedType {
/// `type "X" { struct { field ... } }` — a Pydantic-model-shaped record.
Struct(Vec<StructField>),
/// `type "X" { alias { <type-child> } }` — a named wrapper around an
/// inline type shape, e.g. `userOrdersOutput = list[OrderOutput]`.
Alias(TypeShape),
/// `type "X" { enum "A" "B" ... }` — a string-literal enum.
Enum(Vec<&'static str>),
}
/// The set of in-place type shapes referenced from struct fields, function
/// inputs/outputs, and alias bodies.
#[derive(Debug, Clone)]
pub enum TypeShape {
Primitive(Primitive),
Ref(&'static str),
List(Box<TypeShape>),
Optional(Box<TypeShape>),
Enum(Vec<&'static str>),
Union(Vec<TypeShape>),
}
#[derive(Debug, Clone, Copy)]
pub enum Primitive {
Integer,
Number,
Boolean,
String,
}
impl Primitive {
pub fn name(self) -> &'static str {
match self {
Primitive::Integer => "integer",
Primitive::Number => "number",
Primitive::Boolean => "boolean",
Primitive::String => "string",
}
}
}
#[derive(Debug, Clone)]
pub struct StructField {
pub name: &'static str,
pub required: bool,
pub default: Option<DefaultValue>,
pub shape: TypeShape,
}
#[derive(Debug, Clone)]
pub enum DefaultValue {
Integer(i64),
Number(f64),
Boolean(bool),
String(&'static str),
Null,
}
/// One descriptor of what a mutation `affects`. Mirrors Python's
/// `_normalize_affects` shape — either a named context or a named function.
#[derive(Debug, Clone)]
pub enum AffectTarget {
Context(&'static str),
Function {
name: &'static str,
context: Option<&'static str>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Http,
Websocket,
Both,
}
impl Transport {
pub fn name(self) -> &'static str {
match self {
Transport::Http => "http",
Transport::Websocket => "websocket",
Transport::Both => "both",
}
}
}

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

@@ -0,0 +1,397 @@
//! KDL emitter — byte-equivalent to `cores/mizan-python/src/mizan_core/ir.py`.
//!
//! The Python emitter is the spec; this is the second implementation under
//! the same contract. Any divergence is a bug here, not a contract change.
use crate::ir::{DefaultValue, NamedType, Primitive, StructField, TypeShape};
use crate::registry::{CONTEXTS, FUNCTIONS, TYPES};
use crate::traits::FunctionSpec;
use std::collections::BTreeMap;
const INDENT: &str = " ";
/// Escape a string for KDL — same escape set as the Python emitter.
fn kdl_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
other => out.push(other),
}
}
out.push('"');
out
}
fn kdl_bool(b: bool) -> &'static str {
if b {
"#true"
} else {
"#false"
}
}
fn kdl_default(v: &DefaultValue) -> String {
match v {
DefaultValue::Null => "#null".into(),
DefaultValue::Boolean(b) => kdl_bool(*b).into(),
DefaultValue::Integer(i) => i.to_string(),
DefaultValue::Number(f) => {
// Match Python's `repr(float)` for whole-number-equal-but-float
// values: e.g. 1.0 → "1.0", not "1".
if f.fract() == 0.0 && f.is_finite() {
format!("{f:.1}")
} else {
f.to_string()
}
}
DefaultValue::String(s) => kdl_string(s),
}
}
/// Convert snake_case to camelCase. Matches Python's `_snake_to_camel`.
pub fn snake_to_camel(name: &str) -> String {
let normalized = name.replace('.', "_").replace('-', "_");
let mut parts = normalized.split('_');
let mut out = String::new();
if let Some(first) = parts.next() {
out.push_str(first);
}
for part in parts {
if part.is_empty() {
continue;
}
let mut chars = part.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
struct Emitter {
lines: Vec<String>,
}
impl Emitter {
fn new() -> Self {
Self { lines: Vec::new() }
}
fn prefix(&self, indent: usize) -> String {
INDENT.repeat(indent)
}
fn leaf(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
self.lines.push(line);
}
fn open(&mut self, indent: usize, parts: &[&str]) {
let mut line = self.prefix(indent);
line.push_str(&parts.join(" "));
line.push_str(" {");
self.lines.push(line);
}
fn close(&mut self, indent: usize) {
let mut line = self.prefix(indent);
line.push('}');
self.lines.push(line);
}
fn blank(&mut self) {
self.lines.push(String::new());
}
fn emit_type_child(&mut self, indent: usize, shape: &TypeShape) {
match shape {
TypeShape::Primitive(p) => {
let name = kdl_string(p.name());
self.leaf(indent, &["primitive", &name]);
}
TypeShape::Ref(name) => {
let n = kdl_string(name);
self.leaf(indent, &["ref", &n]);
}
TypeShape::List(inner) => {
self.open(indent, &["list"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
}
TypeShape::Optional(inner) => {
self.open(indent, &["optional"]);
self.emit_type_child(indent + 1, inner);
self.close(indent);
}
TypeShape::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent, &line);
}
TypeShape::Union(branches) => {
self.open(indent, &["union"]);
for b in branches {
self.emit_type_child(indent + 1, b);
}
self.close(indent);
}
}
}
fn emit_named_type(&mut self, indent: usize, name: &str, body: &NamedType) {
let name_lit = kdl_string(name);
self.open(indent, &["type", &name_lit]);
match body {
NamedType::Struct(fields) => {
self.open(indent + 1, &["struct"]);
for field in fields {
self.emit_struct_field(indent + 2, field);
}
self.close(indent + 1);
}
NamedType::Alias(inner) => {
self.open(indent + 1, &["alias"]);
self.emit_type_child(indent + 2, inner);
self.close(indent + 1);
}
NamedType::Enum(variants) => {
let mut parts: Vec<String> = vec!["enum".into()];
for v in variants {
parts.push(kdl_string(v));
}
let line: Vec<&str> = parts.iter().map(String::as_str).collect();
self.leaf(indent + 1, &line);
}
}
self.close(indent);
}
fn emit_struct_field(&mut self, indent: usize, field: &StructField) {
let name = kdl_string(field.name);
let mut header: Vec<String> = vec!["field".into(), name];
if !field.required {
header.push(format!("required={}", kdl_bool(false)));
if let Some(default) = &field.default {
header.push(format!("default={}", kdl_default(default)));
}
}
let line_parts: Vec<&str> = header.iter().map(String::as_str).collect();
self.open(indent, &line_parts);
self.emit_type_child(indent + 1, &field.shape);
self.close(indent);
}
fn emit_function(&mut self, indent: usize, fn_spec: &dyn FunctionSpec) {
let name = kdl_string(fn_spec.name());
self.open(indent, &["function", &name]);
let camel = kdl_string(fn_spec.camel_name());
self.leaf(indent + 1, &["camel", &camel]);
self.leaf(indent + 1, &["has-input", kdl_bool(fn_spec.has_input())]);
if let Some(input_type) = fn_spec.input_type() {
let lit = kdl_string(input_type);
self.leaf(indent + 1, &["input", &lit]);
}
let output_lit = kdl_string(fn_spec.output_type());
self.leaf(indent + 1, &["output", &output_lit]);
if fn_spec.output_nullable() {
self.leaf(indent + 1, &["output-nullable", kdl_bool(true)]);
}
let transport_lit = kdl_string(fn_spec.transport().name());
self.leaf(indent + 1, &["transport", &transport_lit]);
if let Some(ctx) = fn_spec.context() {
let lit = kdl_string(ctx);
self.leaf(indent + 1, &["context", &lit]);
}
for affect in fn_spec.affects() {
// Mirror Python's behavior: only context-typed affects make it
// into the KDL `affects` leaf. Function-typed affects are
// reserved for a future IR extension.
if let crate::ir::AffectTarget::Context(name) = affect {
let lit = kdl_string(name);
self.leaf(indent + 1, &["affects", &lit]);
}
}
for merge in fn_spec.merge() {
let lit = kdl_string(merge);
self.leaf(indent + 1, &["merge", &lit]);
}
if fn_spec.is_form() {
self.leaf(indent + 1, &["is-form", kdl_bool(true)]);
if let Some(form_name) = fn_spec.form_name() {
let lit = kdl_string(form_name);
self.leaf(indent + 1, &["form-name", &lit]);
}
if let Some(form_role) = fn_spec.form_role() {
let lit = kdl_string(form_role);
self.leaf(indent + 1, &["form-role", &lit]);
}
}
self.close(indent);
}
fn emit_context(&mut self, indent: usize, ctx_name: &str, members: &[&'static dyn FunctionSpec]) {
let name_lit = kdl_string(ctx_name);
self.open(indent, &["context", &name_lit]);
// Function membership in registration order.
for fn_spec in members {
let lit = kdl_string(fn_spec.name());
self.leaf(indent + 1, &["function", &lit]);
}
// Param info — collect across every member, then emit alphabetized
// by param name to match Python.
struct ParamSlot {
primitive: Primitive,
shared_by: Vec<&'static str>,
}
let mut params: BTreeMap<&'static str, ParamSlot> = BTreeMap::new();
for fn_spec in members {
for p in fn_spec.input_params() {
let slot = params.entry(p.name).or_insert(ParamSlot {
primitive: p.primitive,
shared_by: Vec::new(),
});
slot.primitive = p.primitive;
slot.shared_by.push(fn_spec.name());
}
}
let member_count = members.len();
for (param_name, slot) in params.iter() {
let name_lit = kdl_string(param_name);
self.open(indent + 1, &["param", &name_lit]);
let type_lit = kdl_string(slot.primitive.name());
self.leaf(indent + 2, &["type", &type_lit]);
let required = slot.shared_by.len() == member_count;
self.leaf(indent + 2, &["required", kdl_bool(required)]);
for sharer in &slot.shared_by {
let lit = kdl_string(sharer);
self.leaf(indent + 2, &["shared-by", &lit]);
}
self.close(indent + 1);
}
self.close(indent);
}
fn into_string(mut self) -> String {
// Trim trailing blanks, then add a single terminating newline.
while matches!(self.lines.last(), Some(s) if s.is_empty()) {
self.lines.pop();
}
let mut out = self.lines.join("\n");
out.push('\n');
out
}
}
/// Collected typed registries view used by `build_ir`.
pub(crate) struct IrSnapshot {
pub types: BTreeMap<&'static str, NamedType>,
pub functions: Vec<&'static dyn FunctionSpec>,
pub contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)>,
}
impl IrSnapshot {
pub(crate) fn collect() -> Self {
// Types: alphabetized for byte-equivalence with Python's `sorted(named_types)`.
let mut types: BTreeMap<&'static str, NamedType> = BTreeMap::new();
for entry in TYPES {
types.insert(entry.name, (entry.shape_fn)());
}
// Functions: alphabetical by wire name (canonical IR ordering,
// matches the Python emitter's `sorted(functions)`). Skip `private`.
let mut functions: Vec<&'static dyn FunctionSpec> = FUNCTIONS
.iter()
.copied()
.filter(|f| !f.private())
.collect();
functions.sort_by_key(|f| f.name());
// Contexts: alphabetical by name (canonical IR ordering), each with
// its members sorted alphabetically too.
let mut context_names: Vec<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
context_names.sort();
let mut contexts: Vec<(&'static str, Vec<&'static dyn FunctionSpec>)> = Vec::new();
for name in context_names {
let mut members: Vec<&'static dyn FunctionSpec> = functions
.iter()
.copied()
.filter(|f| f.context() == Some(name))
.collect();
members.sort_by_key(|f| f.name());
if !members.is_empty() {
contexts.push((name, members));
}
}
Self {
types,
functions,
contexts,
}
}
}
/// Build the Mizan IR for every registered type/function/context. Returns KDL.
pub fn build_ir() -> String {
crate::graph_check::verify_invariants();
let snap = IrSnapshot::collect();
let mut em = Emitter::new();
// Type definitions
let types_emitted = !snap.types.is_empty();
for (name, body) in &snap.types {
em.emit_named_type(0, name, body);
}
if types_emitted {
em.blank();
}
// Functions
let fns_emitted = !snap.functions.is_empty();
for fn_spec in &snap.functions {
em.emit_function(0, *fn_spec);
}
if fns_emitted {
em.blank();
}
// Contexts
let ctxs_emitted = !snap.contexts.is_empty();
for (ctx_name, members) in &snap.contexts {
em.emit_context(0, ctx_name, members);
}
if ctxs_emitted {
em.blank();
}
// Future: channels — once channel registry lands on the Rust side.
em.into_string()
}

View File

@@ -0,0 +1,57 @@
//! Mizan server-side IR substrate. Rust analog of `cores/mizan-python/src/mizan_core/`.
//!
//! Three load-bearing concerns:
//!
//! 1. **IR data model + KDL emitter.** `build_ir()` produces byte-equivalent
//! KDL to the Python emitter. Both backends emit the same contract.
//! 2. **Compile-time registry.** Proc macros from `mizan-macros` populate
//! linkme distributed slices (`TYPES`, `CONTEXTS`, `FUNCTIONS`) at the
//! consumer crate's expansion sites.
//! 3. **Runtime helpers.** `compute_invalidation` / `compute_merges` /
//! `lookup_function` ported from `mizan-fastapi`'s executor; the HTTP
//! adapter calls these per request.
//!
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
pub mod graph_check;
pub mod ir;
pub mod kdl;
pub mod registry;
pub mod runtime;
pub mod traits;
pub use ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
};
pub use kdl::{build_ir, snake_to_camel};
pub use registry::{
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
FUNCTIONS, TYPES,
};
pub use runtime::{
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
RequestHandle,
};
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
// Re-export proc macros so consumers depend on one crate.
pub use mizan_macros::{client, context, Mizan};
pub mod prelude {
pub use crate::ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
};
pub use crate::registry::{ContextEntry, TypeEntry};
pub use crate::runtime::{MizanError, RequestHandle};
pub use crate::traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
pub use mizan_macros::Mizan;
}
/// Internal re-exports used by `mizan-macros`-generated code. Not part of
/// the public API — consumers must not depend on names under `__priv`.
#[doc(hidden)]
pub mod __priv {
pub use linkme;
pub use serde_json;
}

View File

@@ -0,0 +1,47 @@
//! Compile-time-populated registries, distributed across the consuming crate's
//! source via linkme. The proc macros emit `#[linkme::distributed_slice(...)]`
//! statics that land here at link time.
use crate::ir::NamedType;
use crate::traits::FunctionSpec;
use linkme::distributed_slice;
/// One named-type registration. Emitted by `#[derive(Mizan)]`.
pub struct TypeEntry {
pub name: &'static str,
pub shape_fn: fn() -> NamedType,
}
/// One context-marker registration. Emitted by `#[mizan::context]`.
pub struct ContextEntry {
pub name: &'static str,
}
#[distributed_slice]
pub static TYPES: [TypeEntry] = [..];
#[distributed_slice]
pub static CONTEXTS: [ContextEntry] = [..];
#[distributed_slice]
pub static FUNCTIONS: [&'static dyn FunctionSpec] = [..];
/// Find a registered function by wire name. Used by the HTTP adapter.
pub fn lookup_function(name: &str) -> Option<&'static dyn FunctionSpec> {
FUNCTIONS.iter().copied().find(|f| f.name() == name)
}
/// Find a registered context by name. Used by graph_check.
pub fn lookup_context(name: &str) -> Option<&'static ContextEntry> {
CONTEXTS.iter().find(|c| c.name == name)
}
/// All functions that declare a given context as their `context` membership.
/// Order matches `FUNCTIONS` iteration order — i.e., registration order.
pub fn context_members(ctx_name: &str) -> Vec<&'static dyn FunctionSpec> {
FUNCTIONS
.iter()
.copied()
.filter(|f| f.context() == Some(ctx_name))
.collect()
}

View File

@@ -0,0 +1,252 @@
//! Runtime helpers — error envelope, request handle, invalidation/merge
//! resolution. Ports `compute_invalidation` / `compute_merges` /
//! `_resolve_merge_slot` / `_scoped_params` from
//! `backends/mizan-fastapi/src/mizan_fastapi/executor.py:189-263`.
use crate::registry::context_members;
use crate::traits::FunctionSpec;
use serde_json::Value;
use std::any::Any;
/// Type-erased handle to the framework's request object. The HTTP adapter
/// stuffs its native `Request` here; user code casts back via the adapter's
/// helper types.
#[derive(Clone)]
pub struct RequestHandle<'a> {
pub inner: &'a (dyn Any + Send + Sync),
}
impl<'a> RequestHandle<'a> {
pub fn new<T: Any + Send + Sync>(req: &'a T) -> Self {
Self { inner: req }
}
pub fn downcast<T: Any + Send + Sync>(&self) -> Option<&'a T> {
self.inner.downcast_ref::<T>()
}
}
/// Mizan's standard error envelope. Mirrors FastAPI's MizanError enum.
#[derive(Debug, Clone)]
pub enum MizanError {
NotFound(String),
BadRequest(String),
ValidationFailed {
message: String,
details: Value,
},
Unauthorized(String),
Forbidden(String),
NotImplementedYet(String),
InternalError(String),
}
impl MizanError {
pub fn code(&self) -> &'static str {
match self {
MizanError::NotFound(_) => "NOT_FOUND",
MizanError::BadRequest(_) => "BAD_REQUEST",
MizanError::ValidationFailed { .. } => "VALIDATION_FAILED",
MizanError::Unauthorized(_) => "UNAUTHORIZED",
MizanError::Forbidden(_) => "FORBIDDEN",
MizanError::NotImplementedYet(_) => "NOT_IMPLEMENTED",
MizanError::InternalError(_) => "INTERNAL_ERROR",
}
}
pub fn message(&self) -> &str {
match self {
MizanError::NotFound(m)
| MizanError::BadRequest(m)
| MizanError::Unauthorized(m)
| MizanError::Forbidden(m)
| MizanError::NotImplementedYet(m)
| MizanError::InternalError(m) => m,
MizanError::ValidationFailed { message, .. } => message,
}
}
pub fn http_status(&self) -> u16 {
match self {
MizanError::NotFound(_) => 404,
MizanError::BadRequest(_) => 400,
MizanError::ValidationFailed { .. } => 422,
MizanError::Unauthorized(_) => 401,
MizanError::Forbidden(_) => 403,
MizanError::NotImplementedYet(_) => 501,
MizanError::InternalError(_) => 500,
}
}
/// JSON envelope shape consumers see on the wire.
pub fn to_json(&self) -> Value {
let mut body = serde_json::Map::new();
body.insert("code".into(), Value::String(self.code().into()));
body.insert("message".into(), Value::String(self.message().into()));
if let MizanError::ValidationFailed { details, .. } = self {
body.insert("details".into(), details.clone());
}
Value::Object({
let mut env = serde_json::Map::new();
env.insert("error".into(), Value::Object(body));
env
})
}
}
/// One entry in the response's `invalidate` array.
#[derive(Debug, Clone)]
pub enum InvalidationTarget {
/// A whole context is invalidated.
Context(String),
/// A context, scoped to specific param values.
ScopedContext {
context: String,
params: serde_json::Map<String, Value>,
},
/// A specific function output is invalidated.
Function(String),
}
impl InvalidationTarget {
pub fn to_json(&self) -> Value {
match self {
InvalidationTarget::Context(name) => Value::String(name.clone()),
InvalidationTarget::ScopedContext { context, params } => {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(context.clone()));
m.insert("params".into(), Value::Object(params.clone()));
Value::Object(m)
}
InvalidationTarget::Function(name) => {
let mut m = serde_json::Map::new();
m.insert("function".into(), Value::String(name.clone()));
Value::Object(m)
}
}
}
}
/// One entry in the response's `merge` array. Server-resolved slot — the
/// kernel writes the value into `bundle[slot]` directly.
#[derive(Debug, Clone)]
pub struct MergeEntry {
pub context: String,
pub slot: String,
pub value: Value,
pub params: Option<serde_json::Map<String, Value>>,
}
impl MergeEntry {
pub fn to_json(&self) -> Value {
let mut m = serde_json::Map::new();
m.insert("context".into(), Value::String(self.context.clone()));
m.insert("slot".into(), Value::String(self.slot.clone()));
m.insert("value".into(), self.value.clone());
if let Some(params) = &self.params {
m.insert("params".into(), Value::Object(params.clone()));
}
Value::Object(m)
}
}
/// Build the `invalidate` list from a function's `affects` metadata,
/// auto-scoping when arg names match context params.
pub fn compute_invalidation(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
) -> Vec<InvalidationTarget> {
fn_spec
.affects()
.iter()
.map(|target| match target {
crate::ir::AffectTarget::Context(name) => {
let scoped = scoped_params(name, args);
if scoped.is_empty() {
InvalidationTarget::Context((*name).into())
} else {
InvalidationTarget::ScopedContext {
context: (*name).into(),
params: scoped,
}
}
}
crate::ir::AffectTarget::Function { name, .. } => {
InvalidationTarget::Function((*name).into())
}
})
.collect()
}
/// Build the `merge` list from a function's `merge` metadata. Each entry
/// names the slot inside the context bundle the return value lands in.
pub fn compute_merges(
fn_spec: &dyn FunctionSpec,
args: &serde_json::Map<String, Value>,
result: &Value,
) -> Vec<MergeEntry> {
let targets = fn_spec.merge();
if targets.is_empty() {
return Vec::new();
}
let mutation_output = fn_spec.output_type();
let mut out = Vec::new();
for ctx_name in targets {
let slot = match resolve_merge_slot(ctx_name, mutation_output) {
Some(s) => s,
None => continue,
};
let scoped = scoped_params(ctx_name, args);
out.push(MergeEntry {
context: (*ctx_name).into(),
slot,
value: result.clone(),
params: if scoped.is_empty() {
None
} else {
Some(scoped)
},
});
}
out
}
/// Find the unique function-name slot whose Output type matches the
/// mutation's Output type. Matches Python's `types_match_for_merge` —
/// structural shape comparison, not name comparison. Returns None on no
/// match or ambiguous match.
fn resolve_merge_slot(context_name: &str, mutation_output: &str) -> Option<String> {
let mutation_shape = crate::graph_check::resolve_type_shape(mutation_output)?;
let mut matches: Vec<&'static str> = Vec::new();
for fn_spec in context_members(context_name) {
if let Some(candidate_shape) = crate::graph_check::resolve_type_shape(fn_spec.output_type())
{
if crate::graph_check::types_match(&candidate_shape, &mutation_shape) {
matches.push(fn_spec.name());
}
}
}
if matches.len() == 1 {
Some(matches[0].into())
} else {
None
}
}
/// Match input args against the context's declared Input field names.
fn scoped_params(
context_name: &str,
args: &serde_json::Map<String, Value>,
) -> serde_json::Map<String, Value> {
let mut declared: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for fn_spec in context_members(context_name) {
for p in fn_spec.input_params() {
declared.insert(p.name);
}
}
args.iter()
.filter(|(k, _)| declared.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}

View File

@@ -0,0 +1,92 @@
//! Surface traits the proc macros implement.
use crate::ir::{AffectTarget, NamedType, Transport};
use crate::runtime::{MizanError, RequestHandle};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
/// A type that participates in the Mizan IR. Generated by `#[derive(Mizan)]`.
///
/// `TYPE_NAME` is a `const` (not a function) so it's usable in `static`
/// initializers — TypeEntry's `name` field reads it directly without an
/// init-time function call.
pub trait MizanType {
const TYPE_NAME: &'static str;
fn shape() -> NamedType;
fn type_name() -> &'static str {
Self::TYPE_NAME
}
}
/// A marker type for a Mizan context. Generated by `#[mizan::context]`.
pub trait ContextMarker {
const NAME: &'static str;
}
/// One Mizan-registered function. Generated by `#[mizan(...)]` on async fns.
///
/// Everything here is plain data except `dispatch`, which is the type-erased
/// runtime entry point used by the HTTP adapter.
pub trait FunctionSpec: Send + Sync {
fn name(&self) -> &'static str;
fn camel_name(&self) -> &'static str;
fn has_input(&self) -> bool;
fn input_type(&self) -> Option<&'static str>;
fn output_type(&self) -> &'static str;
fn output_nullable(&self) -> bool {
false
}
fn context(&self) -> Option<&'static str> {
None
}
fn affects(&self) -> &'static [AffectTarget] {
&[]
}
fn merge(&self) -> &'static [&'static str] {
&[]
}
fn transport(&self) -> Transport {
Transport::Http
}
fn private(&self) -> bool {
false
}
fn is_form(&self) -> bool {
false
}
fn form_name(&self) -> Option<&'static str> {
None
}
fn form_role(&self) -> Option<&'static str> {
None
}
/// Field-shape description of this function's Input parameters, used by
/// the context builder to compute shared-param elevation. Empty when
/// `has_input()` is false.
fn input_params(&self) -> &'static [InputParam] {
&[]
}
/// Type-erased dispatch. The HTTP adapter calls this with deserialized
/// JSON arguments; the macro-generated impl deserializes into the
/// function's typed input, awaits the body, and serializes the result.
fn dispatch<'a>(
&'a self,
req: RequestHandle<'a>,
args: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, MizanError>> + Send + 'a>>;
}
/// One parameter of a function's synthesized Input. The macro emits a static
/// slice of these so the context builder can find shared params across
/// context members and produce the `context { param ... shared-by ... }`
/// section of the IR.
#[derive(Debug, Clone, Copy)]
pub struct InputParam {
pub name: &'static str,
pub primitive: crate::ir::Primitive,
pub required: bool,
}

View File

@@ -0,0 +1,129 @@
//! Byte-equivalence: the Rust KDL emitter (driven by the proc macros)
//! against `protocol/mizan-codegen/tests/fixtures/afi_ir.kdl` (canonical
//! Python-emitted reference).
//!
//! This is the Phase-2 verifier — the AFI fixture is authored against the
//! real consumer surface (`#[derive(Mizan)] / #[mizan::context] /
//! #[mizan::client]`), not hand-built static specs.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::RequestHandle;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
// ─── Output / shared types ──────────────────────────────────────────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct EchoOutput {
pub message: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct WhoamiOutput {
pub email: String,
pub authenticated: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct ProfileOutput {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct OrderOutput {
pub id: i64,
pub user_id: i64,
pub total: i64,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct StatusOutput {
pub ok: bool,
}
#[mizan::context("user")]
pub struct UserCtx;
// ─── Fixture functions (mirroring tests/afi/fixture.py) ────────────────────
#[mizan::client]
pub async fn echo(_req: &RequestHandle<'_>, text: String) -> EchoOutput {
EchoOutput {
message: format!("echo: {text}"),
}
}
#[mizan::client]
pub async fn whoami(_req: &RequestHandle<'_>) -> WhoamiOutput {
WhoamiOutput {
email: "anon@example.com".into(),
authenticated: false,
}
}
#[mizan::client(context = UserCtx)]
pub async fn user_profile(_req: &RequestHandle<'_>, user_id: i64) -> ProfileOutput {
ProfileOutput {
user_id,
name: "placeholder".into(),
}
}
#[mizan::client(context = UserCtx)]
pub async fn user_orders(_req: &RequestHandle<'_>, _user_id: i64) -> Vec<OrderOutput> {
vec![]
}
#[mizan::client(affects = UserCtx)]
pub async fn update_profile(
_req: &RequestHandle<'_>,
_user_id: i64,
_name: String,
) -> StatusOutput {
StatusOutput { ok: true }
}
#[mizan::client]
pub async fn find_user(_req: &RequestHandle<'_>, _user_id: i64) -> Option<ProfileOutput> {
None
}
#[mizan::client(merge = UserCtx)]
pub async fn rename_user(
_req: &RequestHandle<'_>,
user_id: i64,
name: String,
) -> ProfileOutput {
ProfileOutput { user_id, name }
}
// ─── The byte-equivalence test ──────────────────────────────────────────────
fn canonical_kdl_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../protocol/mizan-codegen/tests/fixtures/afi_ir.kdl")
}
#[test]
fn build_ir_matches_canonical_afi_kdl() {
let expected = std::fs::read_to_string(canonical_kdl_path()).expect("read canonical KDL");
let actual = mizan_core::build_ir();
if actual != expected {
for (lineno, (a, b)) in actual.lines().zip(expected.lines()).enumerate() {
if a != b {
panic!(
"KDL diverges at line {}:\n expected: {b:?}\n actual: {a:?}",
lineno + 1,
);
}
}
panic!(
"KDL diverges in length: actual_len={} expected_len={}",
actual.len(),
expected.len(),
);
}
}