1use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
14use crate::protocol::ProtocolKind;
15use serde::{Deserialize, Serialize};
16
17pub fn rust_type_to_ts(rust_ty: &str) -> String {
26 match rust_ty {
27 "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128"
29 | "isize" | "f32" | "f64" => "number".to_string(),
30
31 "bool" => "boolean".to_string(),
33
34 "String" | "&str" | "str" => "string".to_string(),
36
37 "()" => "void".to_string(),
39
40 other => {
41 if let Some(inner) = other.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
43 return format!("{}[]", rust_type_to_ts(inner));
44 }
45
46 if let Some(inner) = other
48 .strip_prefix("Option<")
49 .and_then(|s| s.strip_suffix('>'))
50 {
51 return format!("{} | null", rust_type_to_ts(inner));
52 }
53
54 if let Some(inner) = other
56 .strip_prefix("HashMap<")
57 .and_then(|s| s.strip_suffix('>'))
58 && let Some((k, v)) = inner.split_once(',')
59 {
60 return format!(
61 "Record<{}, {}>",
62 rust_type_to_ts(k.trim()),
63 rust_type_to_ts(v.trim())
64 );
65 }
66
67 other.to_string()
69 }
70 }
71}
72
73fn emit_ts_interface(entry: &ManifestEntry) -> String {
79 let mut out = String::new();
80
81 if !entry.doc.is_empty() {
83 out.push_str(&format!("/** {} */\n", entry.doc));
84 }
85
86 if entry.fields.is_empty() {
87 out.push_str(&format!(
89 "export type {} = Record<string, never>;\n",
90 entry.name
91 ));
92 } else {
93 out.push_str(&format!("export interface {} {{\n", entry.name));
94 for field in &entry.fields {
95 let ts_type = rust_type_to_ts(&field.ty);
96 out.push_str(&format!(" {}: {};\n", field.name, ts_type));
97 }
98 out.push_str("}\n");
99 }
100
101 out
102}
103
104pub fn generate_typescript(manifest: &ProtocolManifest) -> String {
108 let mut out = String::new();
109
110 out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
111 out.push_str(&format!(
112 "// Protocol: {} | Manifest v{}\n\n",
113 manifest.protocol_version, manifest.manifest_version
114 ));
115
116 let sections: &[(&str, &[ManifestEntry])] = &[
117 ("Commands", &manifest.commands),
118 ("Queries", &manifest.queries),
119 ("Events", &manifest.events),
120 ("DTOs", &manifest.dtos),
121 ];
122
123 for (label, entries) in sections {
124 if entries.is_empty() {
125 continue;
126 }
127 out.push_str(&format!("// — {} —\n\n", label));
128 for entry in *entries {
129 out.push_str(&emit_ts_interface(entry));
130 out.push('\n');
131 }
132 }
133
134 out
135}
136
137pub fn generate_typescript_for_surface(manifest: &ProtocolManifest, surface: &str) -> String {
143 let mut out = String::new();
144
145 out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
146 out.push_str(&format!(
147 "// Protocol: {} | Surface: {} | Manifest v{}\n\n",
148 manifest.protocol_version, surface, manifest.manifest_version
149 ));
150
151 let belongs = |entry: &ManifestEntry| {
152 ProtocolManifest::entry_belongs_to_surface(entry, surface, &manifest.default_surface)
153 };
154
155 let sections: &[(&str, &Vec<ManifestEntry>)] = &[
156 ("Commands", &manifest.commands),
157 ("Queries", &manifest.queries),
158 ("Events", &manifest.events),
159 ("DTOs", &manifest.dtos),
160 ];
161
162 for (label, entries) in sections {
163 let filtered: Vec<&ManifestEntry> = entries.iter().filter(|e| belongs(e)).collect();
164 if filtered.is_empty() {
165 continue;
166 }
167 out.push_str(&format!("// — {} —\n\n", label));
168 for entry in filtered {
169 out.push_str(&emit_ts_interface(entry));
170 out.push('\n');
171 }
172 }
173
174 out
175}
176
177pub fn generate_all_surface_typescripts(manifest: &ProtocolManifest) -> Vec<(String, String)> {
183 let mut names = manifest.resolved_surface_names();
184 names.sort();
185 names.dedup();
186
187 if names.len() == 1 {
188 return vec![(names[0].clone(), generate_typescript(manifest))];
189 }
190
191 names
192 .into_iter()
193 .map(|name| {
194 let ts = generate_typescript_for_surface(manifest, &name);
195 (name, ts)
196 })
197 .collect()
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub enum HttpMethod {
207 Get,
208 Post,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ProtocolDescriptor {
217 pub name: String,
219 pub kind: ProtocolKind,
221 pub route: String,
223 pub method: HttpMethod,
225 pub fields: Vec<ManifestField>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct SurfaceDescriptorSet {
232 pub name: String,
234 pub descriptors: Vec<ProtocolDescriptor>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ProtocolDescriptorSet {
241 pub protocol_version: String,
242 pub surfaces: Vec<SurfaceDescriptorSet>,
243}
244
245impl ProtocolDescriptorSet {
246 pub fn iter_descriptors(&self) -> impl Iterator<Item = &ProtocolDescriptor> {
252 self.surfaces
253 .iter()
254 .flat_map(|surface| surface.descriptors.iter())
255 }
256}
257
258fn to_kebab_case(name: &str) -> String {
260 let mut result = String::new();
261 for (i, ch) in name.chars().enumerate() {
262 if ch.is_uppercase() && i > 0 {
263 result.push('-');
264 }
265 result.push(ch.to_ascii_lowercase());
266 }
267 result
268}
269
270pub fn generate_descriptors(manifest: &ProtocolManifest) -> ProtocolDescriptorSet {
277 let mut surfaces = Vec::new();
278 let surface_names = manifest.resolved_surface_names();
279
280 for surface_name in &surface_names {
281 let mut descriptors = Vec::new();
282
283 for entry in manifest.commands.iter().filter(|entry| {
284 ProtocolManifest::entry_belongs_to_surface(
285 entry,
286 surface_name,
287 &manifest.default_surface,
288 )
289 }) {
290 let slug = to_kebab_case(&entry.name);
291 descriptors.push(ProtocolDescriptor {
292 name: entry.name.clone(),
293 kind: ProtocolKind::Command,
294 route: format!("/commands/{}", slug),
295 method: HttpMethod::Post,
296 fields: entry.fields.clone(),
297 });
298 }
299
300 for entry in manifest.queries.iter().filter(|entry| {
301 ProtocolManifest::entry_belongs_to_surface(
302 entry,
303 surface_name,
304 &manifest.default_surface,
305 )
306 }) {
307 let slug = to_kebab_case(&entry.name);
308 let method = if entry.fields.is_empty() {
309 HttpMethod::Get
310 } else {
311 HttpMethod::Post
312 };
313 descriptors.push(ProtocolDescriptor {
314 name: entry.name.clone(),
315 kind: ProtocolKind::Query,
316 route: format!("/queries/{}", slug),
317 method,
318 fields: entry.fields.clone(),
319 });
320 }
321
322 for entry in manifest.events.iter().filter(|entry| {
323 ProtocolManifest::entry_belongs_to_surface(
324 entry,
325 surface_name,
326 &manifest.default_surface,
327 )
328 }) {
329 let slug = to_kebab_case(&entry.name);
330 descriptors.push(ProtocolDescriptor {
331 name: entry.name.clone(),
332 kind: ProtocolKind::Event,
333 route: format!("/events/{}", slug),
334 method: HttpMethod::Get,
335 fields: entry.fields.clone(),
336 });
337 }
338
339 surfaces.push(SurfaceDescriptorSet {
340 name: surface_name.clone(),
341 descriptors,
342 });
343 }
344
345 ProtocolDescriptorSet {
346 protocol_version: manifest.protocol_version.clone(),
347 surfaces,
348 }
349}
350
351#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
362 fn primitives_map_to_ts() {
363 assert_eq!(rust_type_to_ts("u64"), "number");
364 assert_eq!(rust_type_to_ts("f32"), "number");
365 assert_eq!(rust_type_to_ts("bool"), "boolean");
366 assert_eq!(rust_type_to_ts("String"), "string");
367 assert_eq!(rust_type_to_ts("()"), "void");
368 }
369
370 #[test]
371 fn vec_maps_to_array() {
372 assert_eq!(rust_type_to_ts("Vec<u64>"), "number[]");
373 assert_eq!(rust_type_to_ts("Vec<String>"), "string[]");
374 }
375
376 #[test]
377 fn option_maps_to_nullable() {
378 assert_eq!(rust_type_to_ts("Option<u32>"), "number | null");
379 assert_eq!(rust_type_to_ts("Option<String>"), "string | null");
380 }
381
382 #[test]
383 fn hashmap_maps_to_record() {
384 assert_eq!(
385 rust_type_to_ts("HashMap<String, u64>"),
386 "Record<string, number>"
387 );
388 }
389
390 #[test]
391 fn unknown_type_passes_through() {
392 assert_eq!(rust_type_to_ts("UnitView"), "UnitView");
393 assert_eq!(rust_type_to_ts("Vec<UnitView>"), "UnitView[]");
394 }
395
396 fn sample_manifest() -> ProtocolManifest {
399 ProtocolManifest {
400 manifest_version: "2".into(),
401 protocol_version: "test@0.1".into(),
402 default_surface: "default".into(),
403 surfaces: vec!["default".into()],
404 commands: vec![ManifestEntry {
405 name: "SpawnUnit".into(),
406 kind: ProtocolKind::Command,
407 fields: vec![
408 ManifestField {
409 name: "unit_id".into(),
410 ty: "u64".into(),
411 },
412 ManifestField {
413 name: "location_id".into(),
414 ty: "u64".into(),
415 },
416 ],
417 doc: "Spawn a unit at a location.".into(),
418 surfaces: vec![],
419 }],
420 queries: vec![ManifestEntry {
421 name: "GetWorldSnapshot".into(),
422 kind: ProtocolKind::Query,
423 fields: vec![],
424 doc: "Get world status.".into(),
425 surfaces: vec![],
426 }],
427 events: vec![ManifestEntry {
428 name: "UnitDestroyed".into(),
429 kind: ProtocolKind::Event,
430 fields: vec![
431 ManifestField {
432 name: "unit_id".into(),
433 ty: "u64".into(),
434 },
435 ManifestField {
436 name: "destination".into(),
437 ty: "String".into(),
438 },
439 ],
440 doc: "".into(),
441 surfaces: vec![],
442 }],
443 dtos: vec![ManifestEntry {
444 name: "WorldSnapshot".into(),
445 kind: ProtocolKind::Dto,
446 fields: vec![
447 ManifestField {
448 name: "units_active".into(),
449 ty: "u32".into(),
450 },
451 ManifestField {
452 name: "units_idle".into(),
453 ty: "u32".into(),
454 },
455 ],
456 doc: "World overview.".into(),
457 surfaces: vec![],
458 }],
459 }
460 }
461
462 fn sample_multi_surface_manifest() -> ProtocolManifest {
463 ProtocolManifest {
464 manifest_version: "2".into(),
465 protocol_version: "test@0.1".into(),
466 default_surface: "gameplay".into(),
467 surfaces: vec!["authority".into(), "gameplay".into()],
468 commands: vec![
469 ManifestEntry {
470 name: "SpawnUnit".into(),
471 kind: ProtocolKind::Command,
472 fields: vec![ManifestField {
473 name: "unit_id".into(),
474 ty: "u64".into(),
475 }],
476 doc: "".into(),
477 surfaces: vec![],
478 },
479 ManifestEntry {
480 name: "AdminReset".into(),
481 kind: ProtocolKind::Command,
482 fields: vec![ManifestField {
483 name: "zone_id".into(),
484 ty: "u64".into(),
485 }],
486 doc: "".into(),
487 surfaces: vec!["authority".into()],
488 },
489 ],
490 queries: vec![],
491 events: vec![ManifestEntry {
492 name: "UnitDestroyed".into(),
493 kind: ProtocolKind::Event,
494 fields: vec![ManifestField {
495 name: "unit_id".into(),
496 ty: "u64".into(),
497 }],
498 doc: "".into(),
499 surfaces: vec!["authority".into(), "gameplay".into()],
500 }],
501 dtos: vec![],
502 }
503 }
504
505 #[test]
506 fn generate_ts_produces_valid_output() {
507 let ts = generate_typescript(&sample_manifest());
508
509 assert!(ts.contains("Auto-generated by Galeon Engine"));
511 assert!(ts.contains("test@0.1"));
512
513 assert!(ts.contains("export interface SpawnUnit {"));
515 assert!(ts.contains("unit_id: number;"));
516 assert!(ts.contains("location_id: number;"));
517
518 assert!(ts.contains("export type GetWorldSnapshot = Record<string, never>;"));
520
521 assert!(ts.contains("export interface UnitDestroyed {"));
523 assert!(ts.contains("destination: string;"));
524
525 assert!(ts.contains("export interface WorldSnapshot {"));
527 assert!(ts.contains("units_active: number;"));
528
529 assert!(ts.contains("/** Spawn a unit at a location. */"));
531 }
532
533 #[test]
534 fn generate_ts_empty_manifest() {
535 let manifest = ProtocolManifest {
536 manifest_version: "2".into(),
537 protocol_version: "empty@0.0".into(),
538 default_surface: "default".into(),
539 surfaces: vec![],
540 commands: vec![],
541 queries: vec![],
542 events: vec![],
543 dtos: vec![],
544 };
545 let ts = generate_typescript(&manifest);
546 assert!(ts.contains("Auto-generated"));
547 assert!(!ts.contains("Commands"));
549 }
550
551 #[test]
554 fn to_kebab_case_converts_pascal() {
555 assert_eq!(to_kebab_case("SpawnUnit"), "spawn-unit");
556 assert_eq!(to_kebab_case("GetWorldSnapshot"), "get-world-snapshot");
557 assert_eq!(to_kebab_case("A"), "a");
558 }
559
560 #[test]
561 fn generate_descriptors_correct_routes() {
562 let descs = generate_descriptors(&sample_manifest());
563
564 assert_eq!(descs.protocol_version, "test@0.1");
565 assert_eq!(descs.surfaces.len(), 1);
566 assert_eq!(descs.surfaces[0].name, "default");
567 assert_eq!(descs.surfaces[0].descriptors.len(), 3);
569
570 let cmd = descs.surfaces[0]
571 .descriptors
572 .iter()
573 .find(|d| d.name == "SpawnUnit")
574 .unwrap();
575 assert_eq!(cmd.route, "/commands/spawn-unit");
576 assert_eq!(cmd.method, HttpMethod::Post);
577 assert_eq!(cmd.kind, ProtocolKind::Command);
578
579 let query = descs.surfaces[0]
580 .descriptors
581 .iter()
582 .find(|d| d.name == "GetWorldSnapshot")
583 .unwrap();
584 assert_eq!(query.route, "/queries/get-world-snapshot");
585 assert_eq!(query.method, HttpMethod::Get);
586
587 let event = descs.surfaces[0]
588 .descriptors
589 .iter()
590 .find(|d| d.name == "UnitDestroyed")
591 .unwrap();
592 assert_eq!(event.route, "/events/unit-destroyed");
593 assert_eq!(event.method, HttpMethod::Get);
594 }
595
596 #[test]
597 fn generate_descriptors_groups_entries_by_surface() {
598 let descs = generate_descriptors(&sample_multi_surface_manifest());
599
600 assert_eq!(descs.surfaces.len(), 2);
601 let authority = descs
602 .surfaces
603 .iter()
604 .find(|surface| surface.name == "authority")
605 .unwrap();
606 let gameplay = descs
607 .surfaces
608 .iter()
609 .find(|surface| surface.name == "gameplay")
610 .unwrap();
611
612 assert_eq!(authority.descriptors.len(), 2);
613 assert!(
614 authority
615 .descriptors
616 .iter()
617 .any(|desc| desc.name == "AdminReset")
618 );
619 assert!(
620 authority
621 .descriptors
622 .iter()
623 .any(|desc| desc.name == "UnitDestroyed")
624 );
625
626 assert_eq!(gameplay.descriptors.len(), 2);
627 assert!(
628 gameplay
629 .descriptors
630 .iter()
631 .any(|desc| desc.name == "SpawnUnit")
632 );
633 assert!(
634 gameplay
635 .descriptors
636 .iter()
637 .any(|desc| desc.name == "UnitDestroyed")
638 );
639 }
640
641 #[test]
642 fn query_with_fields_uses_post() {
643 let manifest = ProtocolManifest {
644 manifest_version: "2".into(),
645 protocol_version: "test@0.1".into(),
646 default_surface: "default".into(),
647 surfaces: vec!["default".into()],
648 commands: vec![],
649 queries: vec![ManifestEntry {
650 name: "SearchUnits".into(),
651 kind: ProtocolKind::Query,
652 fields: vec![ManifestField {
653 name: "name_filter".into(),
654 ty: "String".into(),
655 }],
656 doc: "".into(),
657 surfaces: vec![],
658 }],
659 events: vec![],
660 dtos: vec![],
661 };
662 let descs = generate_descriptors(&manifest);
663 let q = &descs.surfaces[0].descriptors[0];
664 assert_eq!(q.method, HttpMethod::Post);
665 }
666
667 #[test]
668 fn descriptors_serialize_to_json() {
669 let descs = generate_descriptors(&sample_manifest());
670 let json = serde_json::to_string_pretty(&descs).unwrap();
671 assert!(json.contains("/commands/spawn-unit"));
672 assert!(json.contains("\"Post\""));
673 assert!(json.contains("\"surfaces\""));
674
675 let back: ProtocolDescriptorSet = serde_json::from_str(&json).unwrap();
677 assert_eq!(back.surfaces.len(), 1);
678 assert_eq!(back.iter_descriptors().count(), 3);
679 }
680
681 #[test]
682 fn generate_descriptors_derives_surfaces_for_legacy_manifests() {
683 let manifest: ProtocolManifest = serde_json::from_str(
684 r#"{
685 "manifest_version": "1",
686 "protocol_version": "legacy@0.1",
687 "commands": [
688 {
689 "name": "SpawnUnit",
690 "kind": "Command",
691 "fields": [],
692 "doc": ""
693 }
694 ],
695 "queries": [],
696 "events": [],
697 "dtos": []
698 }"#,
699 )
700 .unwrap();
701
702 let descs = generate_descriptors(&manifest);
703 assert_eq!(descs.surfaces.len(), 1);
704 assert_eq!(descs.surfaces[0].name, "default");
705 assert_eq!(descs.surfaces[0].descriptors.len(), 1);
706 assert_eq!(descs.surfaces[0].descriptors[0].name, "SpawnUnit");
707 }
708
709 #[test]
710 fn generate_descriptors_empty_manifest_has_default_surface() {
711 let manifest = ProtocolManifest {
712 manifest_version: "2".into(),
713 protocol_version: "empty@0.0".into(),
714 default_surface: "default".into(),
715 surfaces: vec![],
716 commands: vec![],
717 queries: vec![],
718 events: vec![],
719 dtos: vec![],
720 };
721 let descs = generate_descriptors(&manifest);
722 assert_eq!(descs.surfaces.len(), 1);
723 assert_eq!(descs.surfaces[0].name, "default");
724 assert!(descs.surfaces[0].descriptors.is_empty());
725 }
726
727 #[test]
730 fn generate_ts_for_surface_filters_entries() {
731 let manifest = sample_multi_surface_manifest();
732 let gameplay_ts = generate_typescript_for_surface(&manifest, "gameplay");
733 let authority_ts = generate_typescript_for_surface(&manifest, "authority");
734
735 assert!(gameplay_ts.contains("export interface SpawnUnit"));
737 assert!(!authority_ts.contains("SpawnUnit"));
738
739 assert!(authority_ts.contains("export interface AdminReset"));
741 assert!(!gameplay_ts.contains("AdminReset"));
742
743 assert!(gameplay_ts.contains("export interface UnitDestroyed"));
745 assert!(authority_ts.contains("export interface UnitDestroyed"));
746
747 assert!(gameplay_ts.contains("Surface: gameplay"));
749 assert!(authority_ts.contains("Surface: authority"));
750 }
751
752 #[test]
753 fn generate_ts_for_nonexistent_surface_is_empty() {
754 let manifest = sample_multi_surface_manifest();
755 let ts = generate_typescript_for_surface(&manifest, "nonexistent");
756 assert!(ts.contains("Surface: nonexistent"));
757 assert!(!ts.contains("Commands"));
759 assert!(!ts.contains("Events"));
760 }
761
762 #[test]
763 fn generate_all_surface_typescripts_returns_one_per_surface() {
764 let manifest = sample_multi_surface_manifest();
765 let all = generate_all_surface_typescripts(&manifest);
766
767 assert_eq!(all.len(), 2);
768 assert_eq!(all[0].0, "authority");
769 assert_eq!(all[1].0, "gameplay");
770
771 assert!(all[0].1.contains("AdminReset"));
773 assert!(all[1].1.contains("SpawnUnit"));
774 }
775
776 #[test]
777 fn single_surface_generate_all_matches_generate_typescript() {
778 let manifest = sample_manifest();
779 let all = generate_all_surface_typescripts(&manifest);
780 assert_eq!(all.len(), 1);
781 assert_eq!(all[0].0, "default");
782 assert_eq!(all[0].1, generate_typescript(&manifest));
784 }
785
786 #[test]
787 fn generate_all_surface_typescripts_sorts_and_dedups_surface_names() {
788 let mut manifest = sample_multi_surface_manifest();
789 manifest.surfaces = vec!["gameplay".into(), "authority".into(), "authority".into()];
791
792 let all = generate_all_surface_typescripts(&manifest);
793
794 assert_eq!(all.len(), 2);
795 assert_eq!(all[0].0, "authority");
796 assert_eq!(all[1].0, "gameplay");
797 }
798
799 #[test]
802 fn multi_surface_end_to_end_isolation() {
803 let manifest = sample_multi_surface_manifest();
804
805 let ts_modules = generate_all_surface_typescripts(&manifest);
807 assert_eq!(ts_modules.len(), 2);
808
809 let descs = generate_descriptors(&manifest);
811 assert_eq!(descs.surfaces.len(), 2);
812
813 let authority_ts = &ts_modules.iter().find(|(n, _)| n == "authority").unwrap().1;
814 let gameplay_ts = &ts_modules.iter().find(|(n, _)| n == "gameplay").unwrap().1;
815 let authority_descs = descs
816 .surfaces
817 .iter()
818 .find(|s| s.name == "authority")
819 .unwrap();
820 let gameplay_descs = descs
821 .surfaces
822 .iter()
823 .find(|s| s.name == "gameplay")
824 .unwrap();
825
826 assert!(authority_ts.contains("AdminReset"));
828 assert!(authority_ts.contains("UnitDestroyed"));
829 assert!(!authority_ts.contains("SpawnUnit"));
830 assert_eq!(authority_descs.descriptors.len(), 2);
831
832 assert!(gameplay_ts.contains("SpawnUnit"));
834 assert!(gameplay_ts.contains("UnitDestroyed"));
835 assert!(!gameplay_ts.contains("AdminReset"));
836 assert_eq!(gameplay_descs.descriptors.len(), 2);
837
838 for surface_descs in &descs.surfaces {
840 let ts = if surface_descs.name == "authority" {
841 authority_ts
842 } else {
843 gameplay_ts
844 };
845 for desc in &surface_descs.descriptors {
846 assert!(
847 ts.contains(&desc.name),
848 "surface {:?} descriptor {:?} missing from TS output",
849 surface_descs.name,
850 desc.name
851 );
852 }
853 }
854 }
855}