Replaces the transitional OpenAPI 3.0 + `x-mizan-*` extensions
substrate with the canonical Mizan IR as KDL, per docs/AFI_ARCHITECTURE.md:
"KDL is the contract; everything else (REST envelopes, OpenAPI
documents, framework idioms) is sediment around it."
End-to-end cutover. No transitional path left on main.
Forward direction:
cores/mizan-python/src/mizan_core/ir.py
build_ir() walks mizan_core.registry, introspects Pydantic
models directly (no JSON-Schema indirection), and emits the
Mizan IR document. The KDL grammar is locked in this file's
module docstring.
Backends emit KDL:
backends/mizan-fastapi/src/mizan_fastapi/ir.py
`python -m mizan_fastapi.ir <module>` — CLI entry point.
backends/mizan-django/.../management/commands/export_mizan_ir.py
`manage.py export_mizan_ir` — Django mgmt command.
Codegen consumes KDL:
protocol/mizan-codegen/Cargo.toml: + kdl = "6"
protocol/mizan-codegen/src/ir.rs: NamedType { Struct/List/Enum/Alias }
+ TypeShape { Primitive/Ref/List/Optional/Enum/Union } sum types,
replacing the JsonSchema sprawl. KDL parser walks the
`kdl::KdlDocument` tree into typed Rust structs.
protocol/mizan-codegen/src/fetch.rs: subprocess command switches
to the new IR-export entry points.
All emit modules (stage1 / react / python / rust / vue / svelte /
channels) port their type-walkers from JsonSchema to the new
sum types — case analysis collapses substantially.
Substrate-honesty wins beyond the moat closure:
- `int | bool` multi-arm unions land as `TypeShape::Union` (was
silently coerced to "string" before).
- `<CamelName>Output = list[T]` returns emit as named alias
types instead of struct-shaped wrappers, so consumer code
`.map()` works directly on the type.
- Pydantic field defaults flow through to `default` properties
in KDL, then back to non-optional shape in every target.
Deleted:
- backends/mizan-fastapi/src/mizan_fastapi/{cli,schema}.py
- backends/mizan-django/.../export_mizan_schema.py
- openapi-bearing half of mizan/export/__init__.py (edge
manifest generator preserved — separate concern).
- tests/afi/schema_normalizer.py
- tests/fixtures/{afi_schema.json, channels_schema.json}
- tests/fixtures/js_* baseline directories.
Verification:
- 20 mizan-codegen unit tests green (IR deserialization,
byte-equivalence parity across stage1/rust/python/react/vue/svelte
against fresh KDL-driven baselines, channels structural).
- tests/rust/run_wire_parity.py: 12/12 probes green driving
the binary end-to-end through KDL.
- Blazr studio-ui typechecks against the regenerated React client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.3 KiB
Rust
165 lines
5.3 KiB
Rust
//! `mizan-generate` — Rust codegen binary.
|
|
//!
|
|
//! Replaces the Node-based `protocol/mizan-generate/generator/cli.mjs`.
|
|
//! Reads `mizan.toml`, spawns the configured backend to fetch the IR, and
|
|
//! dispatches each `--target` to its `CodegenTarget` impl. Per-target file
|
|
//! emission writes under the configured `output` directory.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
|
|
use mizan_codegen::{config, emit, fetch};
|
|
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(
|
|
name = "mizan-generate",
|
|
about = "Mizan code generator — consumes Mizan IR; emits typed clients.",
|
|
)]
|
|
struct Cli {
|
|
/// Path to the codegen config file.
|
|
#[arg(short, long, default_value = "mizan.toml")]
|
|
config: PathBuf,
|
|
|
|
/// Output directory (overrides `output` in config).
|
|
#[arg(short, long)]
|
|
output: Option<PathBuf>,
|
|
|
|
/// Comma-separated list of targets (overrides `targets` in config).
|
|
#[arg(short, long)]
|
|
target: Option<String>,
|
|
|
|
/// Read the IR from a JSON file instead of spawning the backend's
|
|
/// schema-export command. The fixture path used by integration tests.
|
|
#[arg(long)]
|
|
from_json: Option<PathBuf>,
|
|
}
|
|
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
let config_exists = cli.config.exists();
|
|
let mut config: config::Config = if config_exists {
|
|
let config_text = fs::read_to_string(&cli.config)
|
|
.with_context(|| format!("reading config: {}", cli.config.display()))?;
|
|
toml::from_str(&config_text)
|
|
.with_context(|| format!("parsing TOML: {}", cli.config.display()))?
|
|
} else if cli.from_json.is_some() {
|
|
// --from-json bypasses the fetcher, so a missing config is fine —
|
|
// CLI flags supply output + targets.
|
|
config::Config {
|
|
project_id: None,
|
|
output: PathBuf::from("."),
|
|
targets: vec![],
|
|
source: Default::default(),
|
|
rust_kernel: None,
|
|
rust_crate_name: None,
|
|
}
|
|
} else {
|
|
return Err(anyhow::anyhow!(
|
|
"config not found: {} (pass --from-json to skip fetch)",
|
|
cli.config.display(),
|
|
));
|
|
};
|
|
|
|
if let Some(o) = cli.output {
|
|
config.output = o;
|
|
}
|
|
if let Some(t) = cli.target {
|
|
config.targets = t.split(',').map(|s| s.trim().to_string()).collect();
|
|
}
|
|
|
|
let config_dir = if config_exists {
|
|
resolve_config_dir(&cli.config)?
|
|
} else {
|
|
std::env::current_dir()?
|
|
};
|
|
|
|
let ir = if let Some(json_path) = &cli.from_json {
|
|
let abs = if json_path.is_absolute() {
|
|
json_path.clone()
|
|
} else {
|
|
config_dir.join(json_path)
|
|
};
|
|
eprintln!("[mizan] Reading IR from {}", abs.display());
|
|
let raw = fs::read_to_string(&abs)
|
|
.with_context(|| format!("read {}", abs.display()))?;
|
|
fetch::parse_ir_from_str(&raw)?
|
|
} else {
|
|
eprintln!("[mizan] Fetching schema...");
|
|
fetch::fetch_schema(&config, &config_dir)?
|
|
};
|
|
|
|
eprintln!(
|
|
"[mizan] Loaded {} function(s), {} context group(s), {} type(s)",
|
|
ir.functions.len(),
|
|
ir.contexts.len(),
|
|
ir.types.len(),
|
|
);
|
|
|
|
// Stage 1 is the framework-agnostic foundation that react/vue/svelte
|
|
// import from. Auto-include it whenever any consumer of `./index`
|
|
// (the Stage 1 re-export root) is in the target set.
|
|
let needs_stage1 = config.targets.iter()
|
|
.any(|t| matches!(t.as_str(), "react" | "vue" | "svelte"));
|
|
if needs_stage1 && !config.targets.iter().any(|t| t == "stage1") {
|
|
config.targets.insert(0, "stage1".to_string());
|
|
}
|
|
// Channels schema piggybacks on the main schema (x-mizan-channels);
|
|
// auto-include the channels emit when react is the target and the
|
|
// schema actually carries channels.
|
|
if config.targets.iter().any(|t| t == "react")
|
|
&& !ir.channels.is_empty()
|
|
&& !config.targets.iter().any(|t| t == "channels")
|
|
{
|
|
config.targets.push("channels".to_string());
|
|
}
|
|
|
|
eprintln!("[mizan] Targets: {}", config.targets.join(", "));
|
|
|
|
let output_dir = if config.output.is_absolute() {
|
|
config.output.clone()
|
|
} else {
|
|
config_dir.join(&config.output)
|
|
};
|
|
|
|
for target_name in &config.targets {
|
|
let Some(target) = emit::target_by_name(target_name) else {
|
|
eprintln!("[mizan] WARN: target '{target_name}' has no emitter yet (Phase 2 scaffold)");
|
|
continue;
|
|
};
|
|
let files = target.emit(&ir, &config);
|
|
for file in files {
|
|
let path = output_dir.join(&file.rel_path);
|
|
write_output(&path, &file.content)?;
|
|
eprintln!("[mizan] {} -> {}", target.name(), file.rel_path.display());
|
|
}
|
|
}
|
|
|
|
eprintln!("[mizan] Generation complete.");
|
|
Ok(())
|
|
}
|
|
|
|
|
|
fn resolve_config_dir(config_path: &Path) -> Result<PathBuf> {
|
|
let abs = fs::canonicalize(config_path)
|
|
.with_context(|| format!("canonicalize {}", config_path.display()))?;
|
|
Ok(abs
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from(".")))
|
|
}
|
|
|
|
|
|
fn write_output(path: &Path, content: &str) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("mkdir {}", parent.display()))?;
|
|
}
|
|
fs::write(path, content).with_context(|| format!("write {}", path.display()))
|
|
}
|