use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
use crate::protocol::ProtocolKind;
use serde::{Deserialize, Serialize};
pub fn rust_type_to_ts(rust_ty: &str) -> String {
match rust_ty {
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128"
| "isize" | "f32" | "f64" => "number".to_string(),
"bool" => "boolean".to_string(),
"String" | "&str" | "str" => "string".to_string(),
"()" => "void".to_string(),
other => {
if let Some(inner) = other.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
return format!("{}[]", rust_type_to_ts(inner));
}
if let Some(inner) = other
.strip_prefix("Option<")
.and_then(|s| s.strip_suffix('>'))
{
return format!("{} | null", rust_type_to_ts(inner));
}
if let Some(inner) = other
.strip_prefix("HashMap<")
.and_then(|s| s.strip_suffix('>'))
&& let Some((k, v)) = inner.split_once(',')
{
return format!(
"Record<{}, {}>",
rust_type_to_ts(k.trim()),
rust_type_to_ts(v.trim())
);
}
other.to_string()
}
}
}
fn emit_ts_interface(entry: &ManifestEntry) -> String {
let mut out = String::new();
if !entry.doc.is_empty() {
out.push_str(&format!("/** {} */\n", entry.doc));
}
if entry.fields.is_empty() {
out.push_str(&format!(
"export type {} = Record<string, never>;\n",
entry.name
));
} else {
out.push_str(&format!("export interface {} {{\n", entry.name));
for field in &entry.fields {
let ts_type = rust_type_to_ts(&field.ty);
out.push_str(&format!(" {}: {};\n", field.name, ts_type));
}
out.push_str("}\n");
}
out
}
pub fn generate_typescript(manifest: &ProtocolManifest) -> String {
let mut out = String::new();
out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
out.push_str(&format!(
"// Protocol: {} | Manifest v{}\n\n",
manifest.protocol_version, manifest.manifest_version
));
let sections: &[(&str, &[ManifestEntry])] = &[
("Commands", &manifest.commands),
("Queries", &manifest.queries),
("Events", &manifest.events),
("DTOs", &manifest.dtos),
];
for (label, entries) in sections {
if entries.is_empty() {
continue;
}
out.push_str(&format!("// — {} —\n\n", label));
for entry in *entries {
out.push_str(&emit_ts_interface(entry));
out.push('\n');
}
}
out
}
pub fn generate_typescript_for_surface(manifest: &ProtocolManifest, surface: &str) -> String {
let mut out = String::new();
out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
out.push_str(&format!(
"// Protocol: {} | Surface: {} | Manifest v{}\n\n",
manifest.protocol_version, surface, manifest.manifest_version
));
let belongs = |entry: &ManifestEntry| {
ProtocolManifest::entry_belongs_to_surface(entry, surface, &manifest.default_surface)
};
let sections: &[(&str, &Vec<ManifestEntry>)] = &[
("Commands", &manifest.commands),
("Queries", &manifest.queries),
("Events", &manifest.events),
("DTOs", &manifest.dtos),
];
for (label, entries) in sections {
let filtered: Vec<&ManifestEntry> = entries.iter().filter(|e| belongs(e)).collect();
if filtered.is_empty() {
continue;
}
out.push_str(&format!("// — {} —\n\n", label));
for entry in filtered {
out.push_str(&emit_ts_interface(entry));
out.push('\n');
}
}
out
}
pub fn generate_all_surface_typescripts(manifest: &ProtocolManifest) -> Vec<(String, String)> {
let mut names = manifest.resolved_surface_names();
names.sort();
names.dedup();
if names.len() == 1 {
return vec![(names[0].clone(), generate_typescript(manifest))];
}
names
.into_iter()
.map(|name| {
let ts = generate_typescript_for_surface(manifest, &name);
(name, ts)
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HttpMethod {
Get,
Post,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptor {
pub name: String,
pub kind: ProtocolKind,
pub route: String,
pub method: HttpMethod,
pub fields: Vec<ManifestField>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceDescriptorSet {
pub name: String,
pub descriptors: Vec<ProtocolDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtocolDescriptorSet {
pub protocol_version: String,
pub surfaces: Vec<SurfaceDescriptorSet>,
}
impl ProtocolDescriptorSet {
pub fn iter_descriptors(&self) -> impl Iterator<Item = &ProtocolDescriptor> {
self.surfaces
.iter()
.flat_map(|surface| surface.descriptors.iter())
}
}
fn to_kebab_case(name: &str) -> String {
let mut result = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('-');
}
result.push(ch.to_ascii_lowercase());
}
result
}
pub fn generate_descriptors(manifest: &ProtocolManifest) -> ProtocolDescriptorSet {
let mut surfaces = Vec::new();
let surface_names = manifest.resolved_surface_names();
for surface_name in &surface_names {
let mut descriptors = Vec::new();
for entry in manifest.commands.iter().filter(|entry| {
ProtocolManifest::entry_belongs_to_surface(
entry,
surface_name,
&manifest.default_surface,
)
}) {
let slug = to_kebab_case(&entry.name);
descriptors.push(ProtocolDescriptor {
name: entry.name.clone(),
kind: ProtocolKind::Command,
route: format!("/commands/{}", slug),
method: HttpMethod::Post,
fields: entry.fields.clone(),
});
}
for entry in manifest.queries.iter().filter(|entry| {
ProtocolManifest::entry_belongs_to_surface(
entry,
surface_name,
&manifest.default_surface,
)
}) {
let slug = to_kebab_case(&entry.name);
let method = if entry.fields.is_empty() {
HttpMethod::Get
} else {
HttpMethod::Post
};
descriptors.push(ProtocolDescriptor {
name: entry.name.clone(),
kind: ProtocolKind::Query,
route: format!("/queries/{}", slug),
method,
fields: entry.fields.clone(),
});
}
for entry in manifest.events.iter().filter(|entry| {
ProtocolManifest::entry_belongs_to_surface(
entry,
surface_name,
&manifest.default_surface,
)
}) {
let slug = to_kebab_case(&entry.name);
descriptors.push(ProtocolDescriptor {
name: entry.name.clone(),
kind: ProtocolKind::Event,
route: format!("/events/{}", slug),
method: HttpMethod::Get,
fields: entry.fields.clone(),
});
}
surfaces.push(SurfaceDescriptorSet {
name: surface_name.clone(),
descriptors,
});
}
ProtocolDescriptorSet {
protocol_version: manifest.protocol_version.clone(),
surfaces,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primitives_map_to_ts() {
assert_eq!(rust_type_to_ts("u64"), "number");
assert_eq!(rust_type_to_ts("f32"), "number");
assert_eq!(rust_type_to_ts("bool"), "boolean");
assert_eq!(rust_type_to_ts("String"), "string");
assert_eq!(rust_type_to_ts("()"), "void");
}
#[test]
fn vec_maps_to_array() {
assert_eq!(rust_type_to_ts("Vec<u64>"), "number[]");
assert_eq!(rust_type_to_ts("Vec<String>"), "string[]");
}
#[test]
fn option_maps_to_nullable() {
assert_eq!(rust_type_to_ts("Option<u32>"), "number | null");
assert_eq!(rust_type_to_ts("Option<String>"), "string | null");
}
#[test]
fn hashmap_maps_to_record() {
assert_eq!(
rust_type_to_ts("HashMap<String, u64>"),
"Record<string, number>"
);
}
#[test]
fn unknown_type_passes_through() {
assert_eq!(rust_type_to_ts("UnitView"), "UnitView");
assert_eq!(rust_type_to_ts("Vec<UnitView>"), "UnitView[]");
}
fn sample_manifest() -> ProtocolManifest {
ProtocolManifest {
manifest_version: "2".into(),
protocol_version: "test@0.1".into(),
default_surface: "default".into(),
surfaces: vec!["default".into()],
commands: vec![ManifestEntry {
name: "SpawnUnit".into(),
kind: ProtocolKind::Command,
fields: vec![
ManifestField {
name: "unit_id".into(),
ty: "u64".into(),
},
ManifestField {
name: "location_id".into(),
ty: "u64".into(),
},
],
doc: "Spawn a unit at a location.".into(),
surfaces: vec![],
}],
queries: vec![ManifestEntry {
name: "GetWorldSnapshot".into(),
kind: ProtocolKind::Query,
fields: vec![],
doc: "Get world status.".into(),
surfaces: vec![],
}],
events: vec![ManifestEntry {
name: "UnitDestroyed".into(),
kind: ProtocolKind::Event,
fields: vec![
ManifestField {
name: "unit_id".into(),
ty: "u64".into(),
},
ManifestField {
name: "destination".into(),
ty: "String".into(),
},
],
doc: "".into(),
surfaces: vec![],
}],
dtos: vec![ManifestEntry {
name: "WorldSnapshot".into(),
kind: ProtocolKind::Dto,
fields: vec![
ManifestField {
name: "units_active".into(),
ty: "u32".into(),
},
ManifestField {
name: "units_idle".into(),
ty: "u32".into(),
},
],
doc: "World overview.".into(),
surfaces: vec![],
}],
}
}
fn sample_multi_surface_manifest() -> ProtocolManifest {
ProtocolManifest {
manifest_version: "2".into(),
protocol_version: "test@0.1".into(),
default_surface: "gameplay".into(),
surfaces: vec!["authority".into(), "gameplay".into()],
commands: vec![
ManifestEntry {
name: "SpawnUnit".into(),
kind: ProtocolKind::Command,
fields: vec![ManifestField {
name: "unit_id".into(),
ty: "u64".into(),
}],
doc: "".into(),
surfaces: vec![],
},
ManifestEntry {
name: "AdminReset".into(),
kind: ProtocolKind::Command,
fields: vec![ManifestField {
name: "zone_id".into(),
ty: "u64".into(),
}],
doc: "".into(),
surfaces: vec!["authority".into()],
},
],
queries: vec![],
events: vec![ManifestEntry {
name: "UnitDestroyed".into(),
kind: ProtocolKind::Event,
fields: vec![ManifestField {
name: "unit_id".into(),
ty: "u64".into(),
}],
doc: "".into(),
surfaces: vec!["authority".into(), "gameplay".into()],
}],
dtos: vec![],
}
}
#[test]
fn generate_ts_produces_valid_output() {
let ts = generate_typescript(&sample_manifest());
assert!(ts.contains("Auto-generated by Galeon Engine"));
assert!(ts.contains("test@0.1"));
assert!(ts.contains("export interface SpawnUnit {"));
assert!(ts.contains("unit_id: number;"));
assert!(ts.contains("location_id: number;"));
assert!(ts.contains("export type GetWorldSnapshot = Record<string, never>;"));
assert!(ts.contains("export interface UnitDestroyed {"));
assert!(ts.contains("destination: string;"));
assert!(ts.contains("export interface WorldSnapshot {"));
assert!(ts.contains("units_active: number;"));
assert!(ts.contains("/** Spawn a unit at a location. */"));
}
#[test]
fn generate_ts_empty_manifest() {
let manifest = ProtocolManifest {
manifest_version: "2".into(),
protocol_version: "empty@0.0".into(),
default_surface: "default".into(),
surfaces: vec![],
commands: vec![],
queries: vec![],
events: vec![],
dtos: vec![],
};
let ts = generate_typescript(&manifest);
assert!(ts.contains("Auto-generated"));
assert!(!ts.contains("Commands"));
}
#[test]
fn to_kebab_case_converts_pascal() {
assert_eq!(to_kebab_case("SpawnUnit"), "spawn-unit");
assert_eq!(to_kebab_case("GetWorldSnapshot"), "get-world-snapshot");
assert_eq!(to_kebab_case("A"), "a");
}
#[test]
fn generate_descriptors_correct_routes() {
let descs = generate_descriptors(&sample_manifest());
assert_eq!(descs.protocol_version, "test@0.1");
assert_eq!(descs.surfaces.len(), 1);
assert_eq!(descs.surfaces[0].name, "default");
assert_eq!(descs.surfaces[0].descriptors.len(), 3);
let cmd = descs.surfaces[0]
.descriptors
.iter()
.find(|d| d.name == "SpawnUnit")
.unwrap();
assert_eq!(cmd.route, "/commands/spawn-unit");
assert_eq!(cmd.method, HttpMethod::Post);
assert_eq!(cmd.kind, ProtocolKind::Command);
let query = descs.surfaces[0]
.descriptors
.iter()
.find(|d| d.name == "GetWorldSnapshot")
.unwrap();
assert_eq!(query.route, "/queries/get-world-snapshot");
assert_eq!(query.method, HttpMethod::Get);
let event = descs.surfaces[0]
.descriptors
.iter()
.find(|d| d.name == "UnitDestroyed")
.unwrap();
assert_eq!(event.route, "/events/unit-destroyed");
assert_eq!(event.method, HttpMethod::Get);
}
#[test]
fn generate_descriptors_groups_entries_by_surface() {
let descs = generate_descriptors(&sample_multi_surface_manifest());
assert_eq!(descs.surfaces.len(), 2);
let authority = descs
.surfaces
.iter()
.find(|surface| surface.name == "authority")
.unwrap();
let gameplay = descs
.surfaces
.iter()
.find(|surface| surface.name == "gameplay")
.unwrap();
assert_eq!(authority.descriptors.len(), 2);
assert!(
authority
.descriptors
.iter()
.any(|desc| desc.name == "AdminReset")
);
assert!(
authority
.descriptors
.iter()
.any(|desc| desc.name == "UnitDestroyed")
);
assert_eq!(gameplay.descriptors.len(), 2);
assert!(
gameplay
.descriptors
.iter()
.any(|desc| desc.name == "SpawnUnit")
);
assert!(
gameplay
.descriptors
.iter()
.any(|desc| desc.name == "UnitDestroyed")
);
}
#[test]
fn query_with_fields_uses_post() {
let manifest = ProtocolManifest {
manifest_version: "2".into(),
protocol_version: "test@0.1".into(),
default_surface: "default".into(),
surfaces: vec!["default".into()],
commands: vec![],
queries: vec![ManifestEntry {
name: "SearchUnits".into(),
kind: ProtocolKind::Query,
fields: vec![ManifestField {
name: "name_filter".into(),
ty: "String".into(),
}],
doc: "".into(),
surfaces: vec![],
}],
events: vec![],
dtos: vec![],
};
let descs = generate_descriptors(&manifest);
let q = &descs.surfaces[0].descriptors[0];
assert_eq!(q.method, HttpMethod::Post);
}
#[test]
fn descriptors_serialize_to_json() {
let descs = generate_descriptors(&sample_manifest());
let json = serde_json::to_string_pretty(&descs).unwrap();
assert!(json.contains("/commands/spawn-unit"));
assert!(json.contains("\"Post\""));
assert!(json.contains("\"surfaces\""));
let back: ProtocolDescriptorSet = serde_json::from_str(&json).unwrap();
assert_eq!(back.surfaces.len(), 1);
assert_eq!(back.iter_descriptors().count(), 3);
}
#[test]
fn generate_descriptors_derives_surfaces_for_legacy_manifests() {
let manifest: ProtocolManifest = serde_json::from_str(
r#"{
"manifest_version": "1",
"protocol_version": "legacy@0.1",
"commands": [
{
"name": "SpawnUnit",
"kind": "Command",
"fields": [],
"doc": ""
}
],
"queries": [],
"events": [],
"dtos": []
}"#,
)
.unwrap();
let descs = generate_descriptors(&manifest);
assert_eq!(descs.surfaces.len(), 1);
assert_eq!(descs.surfaces[0].name, "default");
assert_eq!(descs.surfaces[0].descriptors.len(), 1);
assert_eq!(descs.surfaces[0].descriptors[0].name, "SpawnUnit");
}
#[test]
fn generate_descriptors_empty_manifest_has_default_surface() {
let manifest = ProtocolManifest {
manifest_version: "2".into(),
protocol_version: "empty@0.0".into(),
default_surface: "default".into(),
surfaces: vec![],
commands: vec![],
queries: vec![],
events: vec![],
dtos: vec![],
};
let descs = generate_descriptors(&manifest);
assert_eq!(descs.surfaces.len(), 1);
assert_eq!(descs.surfaces[0].name, "default");
assert!(descs.surfaces[0].descriptors.is_empty());
}
#[test]
fn generate_ts_for_surface_filters_entries() {
let manifest = sample_multi_surface_manifest();
let gameplay_ts = generate_typescript_for_surface(&manifest, "gameplay");
let authority_ts = generate_typescript_for_surface(&manifest, "authority");
assert!(gameplay_ts.contains("export interface SpawnUnit"));
assert!(!authority_ts.contains("SpawnUnit"));
assert!(authority_ts.contains("export interface AdminReset"));
assert!(!gameplay_ts.contains("AdminReset"));
assert!(gameplay_ts.contains("export interface UnitDestroyed"));
assert!(authority_ts.contains("export interface UnitDestroyed"));
assert!(gameplay_ts.contains("Surface: gameplay"));
assert!(authority_ts.contains("Surface: authority"));
}
#[test]
fn generate_ts_for_nonexistent_surface_is_empty() {
let manifest = sample_multi_surface_manifest();
let ts = generate_typescript_for_surface(&manifest, "nonexistent");
assert!(ts.contains("Surface: nonexistent"));
assert!(!ts.contains("Commands"));
assert!(!ts.contains("Events"));
}
#[test]
fn generate_all_surface_typescripts_returns_one_per_surface() {
let manifest = sample_multi_surface_manifest();
let all = generate_all_surface_typescripts(&manifest);
assert_eq!(all.len(), 2);
assert_eq!(all[0].0, "authority");
assert_eq!(all[1].0, "gameplay");
assert!(all[0].1.contains("AdminReset"));
assert!(all[1].1.contains("SpawnUnit"));
}
#[test]
fn single_surface_generate_all_matches_generate_typescript() {
let manifest = sample_manifest();
let all = generate_all_surface_typescripts(&manifest);
assert_eq!(all.len(), 1);
assert_eq!(all[0].0, "default");
assert_eq!(all[0].1, generate_typescript(&manifest));
}
#[test]
fn generate_all_surface_typescripts_sorts_and_dedups_surface_names() {
let mut manifest = sample_multi_surface_manifest();
manifest.surfaces = vec!["gameplay".into(), "authority".into(), "authority".into()];
let all = generate_all_surface_typescripts(&manifest);
assert_eq!(all.len(), 2);
assert_eq!(all[0].0, "authority");
assert_eq!(all[1].0, "gameplay");
}
#[test]
fn multi_surface_end_to_end_isolation() {
let manifest = sample_multi_surface_manifest();
let ts_modules = generate_all_surface_typescripts(&manifest);
assert_eq!(ts_modules.len(), 2);
let descs = generate_descriptors(&manifest);
assert_eq!(descs.surfaces.len(), 2);
let authority_ts = &ts_modules.iter().find(|(n, _)| n == "authority").unwrap().1;
let gameplay_ts = &ts_modules.iter().find(|(n, _)| n == "gameplay").unwrap().1;
let authority_descs = descs
.surfaces
.iter()
.find(|s| s.name == "authority")
.unwrap();
let gameplay_descs = descs
.surfaces
.iter()
.find(|s| s.name == "gameplay")
.unwrap();
assert!(authority_ts.contains("AdminReset"));
assert!(authority_ts.contains("UnitDestroyed"));
assert!(!authority_ts.contains("SpawnUnit"));
assert_eq!(authority_descs.descriptors.len(), 2);
assert!(gameplay_ts.contains("SpawnUnit"));
assert!(gameplay_ts.contains("UnitDestroyed"));
assert!(!gameplay_ts.contains("AdminReset"));
assert_eq!(gameplay_descs.descriptors.len(), 2);
for surface_descs in &descs.surfaces {
let ts = if surface_descs.name == "authority" {
authority_ts
} else {
gameplay_ts
};
for desc in &surface_descs.descriptors {
assert!(
ts.contains(&desc.name),
"surface {:?} descriptor {:?} missing from TS output",
surface_descs.name,
desc.name
);
}
}
}
}