mizan-tauri + Pydantic-aware codegen: Tauri-as-Mizan-backend substrate

Tauri now joins FastAPI/Django/axum as a first-class Mizan backend. The
React frontend calls Mizan-registered functions through Tauri's IPC
with the same {result, invalidate, merge} envelope the HTTP path uses;
the schema flows Pydantic → decoru → Rust → KDL → TS in one
mizan-generate invocation.

New packages:
* backends/mizan-tauri — Tauri plugin exposing a single `mizan_invoke`
  command that routes through mizan-core's FUNCTIONS / CONTEXTS
  registries. No per-function tauri::command; the linkme slice IS the
  dispatch table.
* frontends/mizan-tauri-transport — TS package exporting
  tauriTransport() that wraps invoke('plugin:mizan|mizan_invoke', ...)
  and re-shapes errors into MizanError. Pairs with mizan-tauri.

@mizan/base — pluggable transport:
* Adds MizanTransport interface + transport config field.
* Existing fetch-based body factored into httpTransport() (default).
* mizanCall/mizanFetch delegate to config.transport; merge/invalidate
  side-effects stay in the kernel (transport-agnostic).
* Consumers swap via configure({ transport: tauriTransport() }).

mizan-codegen — Rust source + Pydantic pre-step:
* [source.rust] runs a Cargo bin (cargo run --bin <name>) and parses
  KDL from stdout. The bin uses mizan_core::build_ir() after
  force-linking the consumer's #[derive(Mizan)] / #[mizan::client]
  registrations.
* [source.rust.pydantic] is an optional pre-step that pipes an
  embedded Python bridge (scripts/run_decoru.py) to python and writes
  decoru-emitted Rust types into the consumer crate. The bridge
  auto-discovers BaseModel subclasses AND Enum subclasses
  (last-variant-is-default convention so decoru's impl Default keeps
  compiling against enum-typed fields without explicit Pydantic
  defaults).
* Pure-Rust usage stays intact — omit pydantic block and write Rust
  types by hand.

mizan-macros:
* #[mizan::client] now supports Result<T, MizanError> returns. The
  dispatch wrapper `?`-unwraps the user fn so server-side errors
  surface as the protocol's standard {code, message, details?}
  envelope; T-returning functions stay unchanged.
* #[derive(Mizan)] strips the r# raw-identifier prefix and honors
  field-level #[serde(rename = "...")] when emitting IR field names.
  Matches serde's wire shape — fixes IR-vs-JSON drift for Rust-keyword
  fields (e.g. `r#type` → `type`).

react.tsx template:
* Conditionally emits context-related imports / useContextSubscription
  helper based on has_global || !named_contexts.is_empty(). Consumers
  without contexts (mutation/RPC-only apps like claude-manage) no
  longer get dead imports that trip noUnusedLocals.

Verified end-to-end: cargo build clean across mizan-tauri,
mizan-codegen, AFI rust_app; AFI three-way KDL parity tests pass;
claude-manage migration drives the full stack (Pydantic schema →
generated TS api → Tauri-IPC transport → mizan-core dispatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:01:45 -04:00
parent 54f060c273
commit 22dcf0e3c1
13 changed files with 5478 additions and 39 deletions

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Pydantic → Rust codegen helper invoked by mizan-codegen's
`[source.rust.pydantic]` step.
Reads a JSON payload from argv[1] with keys:
- module: Python module to import (e.g. "claude_manage.schema")
- output: Path to write the generated Rust file
- derives: List of derive identifiers to apply to every emitted item
- header: Optional file prefix (e.g. an AUTO-GENERATED warning)
Discovers every BaseModel subclass declared in the module (handled by
decoru) AND every Enum subclass declared there (handled inline — decoru
itself is scoped to BaseModel). Writes one Rust file containing both.
Bundled with the mizan-codegen binary (include_str!) and piped to
`python -` at codegen time — no install step beyond decoru being
importable in the python environment.
"""
import importlib
import inspect
import json
import sys
import textwrap
from enum import Enum
from pathlib import Path
from pydantic import BaseModel # type: ignore[import-untyped]
from decoru import emit_rust_struct, walk_pydantic_model # type: ignore[import-untyped]
def _declared_in(module, obj) -> bool:
return getattr(obj, "__module__", None) == module.__name__
def discover_models(module) -> list[type[BaseModel]]:
"""BaseModel subclasses declared in this module. Imported helpers are
skipped — only own-module declarations qualify."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, BaseModel)
and obj is not BaseModel
and _declared_in(module, obj)
]
def discover_enums(module) -> list[type[Enum]]:
"""Enum subclasses declared in this module. Filters out the
framework's own Enum class and anything imported from elsewhere."""
return [
obj
for _, obj in inspect.getmembers(module, inspect.isclass)
if issubclass(obj, Enum) and obj is not Enum and _declared_in(module, obj)
]
def _pascal_case(s: str) -> str:
return "".join(part.capitalize() for part in s.split("_") if part)
# Last-variant-is-default matches the catch-all idiom (e.g. `Metadata`
# in `claude_manage.schema.EntryType`). Decoru's `emit_rust_struct`
# emits `impl Default` unconditionally on every BaseModel, so any
# enum-typed field that lacks a Pydantic default must still satisfy
# `EntryType::default()`. Forcing #[default] on the last member keeps
# the generated structs compilable without per-enum config.
_ENUM_TEMPLATE = textwrap.dedent("""\
#[derive({derives})]
#[serde(rename_all = "snake_case")]
pub enum {name} {{
{variants}
}}
""")
def _render_variant(member: Enum, *, is_default: bool) -> str:
pascal = _pascal_case(member.name)
default_attr = " #[default]\n" if is_default else ""
return f"{default_attr} {pascal},"
def emit_rust_enum(enum_class: type[Enum], derives: tuple[str, ...]) -> str:
"""Render a Rust enum with PascalCase variants from Python member
names. Pairs `#[serde(rename_all = "snake_case")]` so the wire form
matches each member's `value`. Adds `Default` to the derives and
marks the last member `#[default]` — see `_ENUM_TEMPLATE` for the
rationale."""
name = enum_class.__name__
members = list(enum_class)
full_derives = ", ".join((*derives, "Default"))
variants = "\n".join(
_render_variant(m, is_default=(i == len(members) - 1))
for i, m in enumerate(members)
)
return _ENUM_TEMPLATE.format(derives=full_derives, name=name, variants=variants)
def main() -> int:
if len(sys.argv) < 2:
sys.stderr.write("run_decoru.py: missing JSON payload argument\n")
return 2
payload = json.loads(sys.argv[1])
module_name: str = payload["module"]
output_path = Path(payload["output"]).resolve()
derives = tuple(payload.get("derives", ()))
header = payload.get("header") or ""
sys.path.insert(0, str(Path.cwd()))
module = importlib.import_module(module_name)
enums = discover_enums(module)
models = discover_models(module)
if not enums and not models:
sys.stderr.write(
f"run_decoru.py: no Enum or BaseModel subclasses declared in {module_name!r}\n"
)
return 3
enum_blocks = [emit_rust_enum(e, derives) for e in enums]
struct_blocks = [emit_rust_struct(walk_pydantic_model(m), derives=derives) for m in models]
body = "\n".join((*enum_blocks, *struct_blocks))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(header + body)
sys.stderr.write(
f"run_decoru.py: wrote {len(enums)} enum(s) + {len(models)} struct(s) to {output_path}\n"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -62,6 +62,13 @@ pub struct SourceConfig {
#[serde(default)]
pub django: Option<DjangoSource>,
/// Canonical "Pydantic + Rust" DX path. The Rust crate is the IR
/// authority; an optional `pydantic` sub-block invokes decoru as a
/// pre-step to author Rust types from Pydantic models. Pure-Rust
/// usage (no Pydantic) just omits the sub-block.
#[serde(default)]
pub rust: Option<RustSource>,
}
@@ -98,6 +105,107 @@ pub struct DjangoSource {
}
/// `[source.rust]` — spawn a Cargo binary that emits the Mizan IR (KDL)
/// to stdout. The binary uses `mizan_core::build_ir()` after force-linking
/// the consumer crate's `#[derive(Mizan)]` types and `#[mizan::client]`
/// functions.
#[derive(Debug, Deserialize)]
pub struct RustSource {
/// Path to the consumer's Cargo.toml, relative to the codegen config
/// directory. Defaults to `Cargo.toml` (i.e. config_dir/Cargo.toml).
#[serde(default)]
pub manifest_path: Option<PathBuf>,
/// Name of the binary under `[[bin]]` that exports the IR. Defaults
/// to `emit-mizan-ir` — the convention this substrate documents.
#[serde(default = "default_rust_bin")]
pub bin: String,
/// Cargo features to enable when building the bin.
#[serde(default)]
pub features: Vec<String>,
/// Build in release mode. Defaults to false (dev mode is faster for
/// codegen, and the binary is throwaway).
#[serde(default)]
pub release: bool,
/// Environment overrides for the cargo subprocess.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Optional pre-step — invoke decoru on a Pydantic source module
/// before running the Cargo bin. When present, the pipeline becomes:
/// 1. python + decoru → write Rust types to `pydantic.output`
/// 2. cargo run --bin <bin> → emit IR to stdout
/// Omit for pure-Rust usage (hand-authored or otherwise-generated
/// Rust types with `#[derive(Mizan)]`).
#[serde(default)]
pub pydantic: Option<PydanticPreStep>,
}
fn default_rust_bin() -> String {
"emit-mizan-ir".to_string()
}
/// Pydantic → Rust pre-step. Runs an embedded Python helper that walks
/// the named module for `BaseModel` subclasses and invokes decoru's
/// `walk_pydantic_model` + `emit_rust_struct` to produce a Rust file.
#[derive(Debug, Deserialize)]
pub struct PydanticPreStep {
/// Python module name to import (e.g. `claude_manage.schema`).
pub module: String,
/// Path to write the generated Rust file, relative to the codegen
/// config directory.
pub output: PathBuf,
/// Working directory for the python subprocess, relative to the
/// codegen config directory. Defaults to the config directory itself.
/// The script prepends this to `sys.path` so the module imports.
#[serde(default)]
pub cwd: Option<PathBuf>,
/// Python executable. Defaults to `python`.
#[serde(default)]
pub python: Option<String>,
/// Full command override (e.g. `["uv", "run", "python"]`). Wins over
/// `python` when present.
#[serde(default)]
pub command: Option<Vec<String>>,
/// Derive macros to apply to every generated struct. The default
/// matches the Mizan-canonical set used in `cores/rust/blazr/session`
/// — serde + mizan_core::Mizan for end-to-end RPC participation.
#[serde(default = "default_pydantic_derives")]
pub derives: Vec<String>,
/// Optional prelude inserted at the top of the generated file
/// (typically a "// AUTO-GENERATED" warning + `use` statements for
/// referenced types not produced by decoru itself).
#[serde(default)]
pub header: Option<String>,
/// Environment overrides for the python subprocess.
#[serde(default)]
pub env: BTreeMap<String, String>,
}
fn default_pydantic_derives() -> Vec<String> {
vec![
"Debug".to_string(),
"Clone".to_string(),
"::serde::Serialize".to_string(),
"::serde::Deserialize".to_string(),
"::mizan_core::Mizan".to_string(),
]
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum RustKernelSpec {

View File

@@ -4,24 +4,48 @@
//! Backends:
//! - FastAPI: `python -m mizan_fastapi.ir <module>`
//! - Django: `python manage.py export_mizan_ir`
//! - Rust: `cargo run --bin <bin>` (consumer-side binary that
//! force-links its `#[derive(Mizan)]` types and
//! `#[mizan::client]` functions, then calls
//! `mizan_core::build_ir()`).
//!
//! The Rust source supports an optional `[source.rust.pydantic]`
//! pre-step that invokes decoru on a Pydantic module to author the
//! Rust types before the cargo bin runs — the "Pydantic + Rust"
//! canonical DX.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::{Command, Stdio};
use anyhow::{anyhow, Context, Result};
use serde_json::json;
use crate::config::{Config, DjangoSource, FastapiSource};
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource};
use crate::ir::{parse_ir, MizanIR};
/// Embedded decoru bridge script — piped to `python -` at codegen time
/// when `[source.rust.pydantic]` is set. The script imports decoru,
/// walks the named module's BaseModel subclasses, and writes a Rust
/// file. See `scripts/run_decoru.py` for the full body.
const DECORU_BRIDGE_SCRIPT: &str = include_str!("../scripts/run_decoru.py");
pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
let raw = if let Some(fa) = &config.source.fastapi {
let raw = if let Some(rs) = &config.source.rust {
if let Some(py) = &rs.pydantic {
run_pydantic_prestep(py, config_dir)
.context("running [source.rust.pydantic] pre-step")?;
}
run_rust(rs, config_dir)?
} else 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]"
"config.source must declare one of [source.rust], [source.fastapi], or [source.django]"
));
};
@@ -111,6 +135,96 @@ fn run_subprocess(
}
fn run_rust(src: &RustSource, config_dir: &Path) -> Result<String> {
let manifest = config_dir.join(
src.manifest_path
.clone()
.unwrap_or_else(|| PathBuf::from("Cargo.toml")),
);
let mut args: Vec<String> = vec![
"run".to_string(),
"--quiet".to_string(),
"--manifest-path".to_string(),
manifest.to_string_lossy().into_owned(),
"--bin".to_string(),
src.bin.clone(),
];
if src.release {
args.push("--release".to_string());
}
if !src.features.is_empty() {
args.push("--features".to_string());
args.push(src.features.join(","));
}
run_subprocess("cargo", &args, config_dir, &src.env, "Rust IR export")
}
fn run_pydantic_prestep(src: &PydanticPreStep, config_dir: &Path) -> Result<()> {
let cwd = match &src.cwd {
Some(rel) => config_dir.join(rel),
None => config_dir.to_path_buf(),
};
let output_abs = if src.output.is_absolute() {
src.output.clone()
} else {
config_dir.join(&src.output)
};
let payload = json!({
"module": &src.module,
"output": output_abs.to_string_lossy(),
"derives": &src.derives,
"header": &src.header,
})
.to_string();
let (program, mut args) = resolve_command(&src.command, &src.python);
// `python -` reads the script body from stdin; the JSON payload is
// passed as argv[1] (which lands on sys.argv[1] inside the script).
args.push("-".to_string());
args.push(payload);
let mut cmd = Command::new(&program);
cmd.args(&args).current_dir(&cwd);
for (k, v) in &src.env {
cmd.env(k, v);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let mut child = cmd
.spawn()
.with_context(|| format!("spawning decoru bridge ({program})"))?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("failed to acquire decoru bridge stdin"))?;
stdin
.write_all(DECORU_BRIDGE_SCRIPT.as_bytes())
.context("piping decoru bridge script to python")?;
}
let status = child
.wait()
.context("waiting for decoru bridge to complete")?;
if !status.success() {
return Err(anyhow!(
"[source.rust.pydantic]: decoru bridge exited with status {:?}",
status.code()
));
}
Ok(())
}
/// Library helper: parse a KDL IR from a string.
pub fn parse_ir_from_str(source: &str) -> Result<MizanIR> {
parse_ir(source)

View File

@@ -2,30 +2,38 @@
// AUTO-GENERATED by mizan — do not edit
{% set has_contexts = has_global || !named_contexts.is_empty() -%}
import {
{% if has_contexts -%}
createContext,
{% endif -%}
useCallback,
{% if has_contexts -%}
useContext,
useEffect,
{% endif -%}
useRef,
useState,
{% if has_contexts -%}
useSyncExternalStore,
{% endif -%}
type ReactNode,
} from 'react'
import {
configure,
initSession,
mizanCall,
mizanFetch,
MizanError,
{% if has_contexts -%}
registerContext,
type ContextState,
{% endif -%}
} from '@mizan/base'
{% if !stage1_imports.is_empty() -%}
import { {{ stage1_imports|join(", ") }} } from './index'
{% endif -%}
{% if has_contexts -%}
// Internal — runs inside a Provider, registers with the kernel exactly once.
function useContextSubscription<T>(
name: string,
@@ -46,6 +54,7 @@ function useContextSubscription<T>(
return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState)
}
{% endif %}
// Internal — wraps an imperative call() with isPending / error state.
interface MutationHook<TArgs, TResult> {