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

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