Skip to main content

galeon_engine/
codegen.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! Protocol code generation — TypeScript types and protocol descriptors.
4//!
5//! Reads a [`ProtocolManifest`] and emits:
6//!
7//! - TypeScript interfaces for commands, queries, events, and DTOs
8//! - Protocol descriptors for adapter generation
9//!
10//! This module is the bridge between Rust protocol definitions and the
11//! generated artifacts that TypeScript consumers and runtime adapters use.
12
13use crate::manifest::{ManifestEntry, ManifestField, ProtocolManifest};
14use crate::protocol::ProtocolKind;
15use serde::{Deserialize, Serialize};
16
17// =============================================================================
18// Rust → TypeScript type mapping
19// =============================================================================
20
21/// Map a Rust type string (from manifest `ty` field) to a TypeScript type.
22///
23/// Handles common primitive and collection types. Unknown types pass through
24/// as-is (assumed to be protocol DTOs that will also be generated).
25pub fn rust_type_to_ts(rust_ty: &str) -> String {
26    match rust_ty {
27        // Numeric types → number
28        "u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128"
29        | "isize" | "f32" | "f64" => "number".to_string(),
30
31        // Boolean
32        "bool" => "boolean".to_string(),
33
34        // String types → string
35        "String" | "&str" | "str" => "string".to_string(),
36
37        // Unit / empty
38        "()" => "void".to_string(),
39
40        other => {
41            // Vec<T> → T[]
42            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            // Option<T> → T | null
47            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            // HashMap<K, V> → Record<K, V>
55            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            // Unknown type → pass through (assumed to be another protocol type)
68            other.to_string()
69        }
70    }
71}
72
73// =============================================================================
74// TypeScript code generation
75// =============================================================================
76
77/// Generate a TypeScript interface from a manifest entry.
78fn emit_ts_interface(entry: &ManifestEntry) -> String {
79    let mut out = String::new();
80
81    // Doc comment
82    if !entry.doc.is_empty() {
83        out.push_str(&format!("/** {} */\n", entry.doc));
84    }
85
86    if entry.fields.is_empty() {
87        // Unit struct → type alias to empty object
88        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
104/// Generate a complete TypeScript module from a protocol manifest.
105///
106/// Emits all protocol types grouped by kind with section comments.
107pub 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
137/// Generate a TypeScript module containing only the items that belong to `surface`.
138///
139/// Items with an explicit `surfaces` list are included when `surface` appears
140/// in that list. Items with an empty `surfaces` list are included when
141/// `surface` matches `manifest.default_surface`.
142pub 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
177/// Generate one TypeScript module per surface in the manifest.
178///
179/// Returns `(surface_name, typescript_source)` pairs sorted by surface name.
180/// Single-surface manifests delegate to [`generate_typescript`] so that
181/// existing consumers see identical output (no `Surface:` header drift).
182pub 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// =============================================================================
201// Protocol descriptors
202// =============================================================================
203
204/// HTTP method for remote adapter routing.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub enum HttpMethod {
207    Get,
208    Post,
209}
210
211/// A protocol descriptor for a command, query, or event.
212///
213/// Descriptors carry the identity and routing metadata that adapters need
214/// to dispatch protocol items. They are generated artifacts — not handwritten.
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ProtocolDescriptor {
217    /// Stable protocol name (e.g., `"SpawnUnit"`).
218    pub name: String,
219    /// Protocol kind.
220    pub kind: ProtocolKind,
221    /// Route path for remote adapter (e.g., `"/commands/spawn-unit"`).
222    pub route: String,
223    /// HTTP method for remote adapter.
224    pub method: HttpMethod,
225    /// Field names (for schema documentation).
226    pub fields: Vec<ManifestField>,
227}
228
229/// A per-surface descriptor group.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct SurfaceDescriptorSet {
232    /// Surface name these descriptors belong to.
233    pub name: String,
234    /// Commands, queries, and events reachable from this surface.
235    pub descriptors: Vec<ProtocolDescriptor>,
236}
237
238/// A complete set of protocol descriptors for a project.
239#[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    /// Iterate all descriptors across every surface group.
247    ///
248    /// Items annotated with multiple surfaces will appear once per surface
249    /// they belong to. Callers that need unique items should deduplicate
250    /// by name.
251    pub fn iter_descriptors(&self) -> impl Iterator<Item = &ProtocolDescriptor> {
252        self.surfaces
253            .iter()
254            .flat_map(|surface| surface.descriptors.iter())
255    }
256}
257
258/// Convert a PascalCase name to a kebab-case route segment.
259fn 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
270/// Generate protocol descriptors from a manifest.
271///
272/// Default routing conventions:
273/// - Commands → `POST /commands/<kebab-name>`
274/// - Queries → `GET /queries/<kebab-name>` (POST if has fields)
275/// - Events → `GET /events/<kebab-name>` (stream subscription)
276pub 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// =============================================================================
352// Tests
353// =============================================================================
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    // -- Type mapping --
360
361    #[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    // -- TS generation --
397
398    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        // Header
510        assert!(ts.contains("Auto-generated by Galeon Engine"));
511        assert!(ts.contains("test@0.1"));
512
513        // Command interface
514        assert!(ts.contains("export interface SpawnUnit {"));
515        assert!(ts.contains("unit_id: number;"));
516        assert!(ts.contains("location_id: number;"));
517
518        // Query unit struct
519        assert!(ts.contains("export type GetWorldSnapshot = Record<string, never>;"));
520
521        // Event interface
522        assert!(ts.contains("export interface UnitDestroyed {"));
523        assert!(ts.contains("destination: string;"));
524
525        // DTO interface
526        assert!(ts.contains("export interface WorldSnapshot {"));
527        assert!(ts.contains("units_active: number;"));
528
529        // Doc comment
530        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        // No section headers for empty categories
548        assert!(!ts.contains("Commands"));
549    }
550
551    // -- Descriptors --
552
553    #[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        // DTOs are type-only — no descriptor (3 = command + query + event)
568        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        // Round-trip
676        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    // -- Per-surface TypeScript generation --
728
729    #[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        // SpawnUnit has no explicit surfaces → belongs to default ("gameplay")
736        assert!(gameplay_ts.contains("export interface SpawnUnit"));
737        assert!(!authority_ts.contains("SpawnUnit"));
738
739        // AdminReset is authority-only
740        assert!(authority_ts.contains("export interface AdminReset"));
741        assert!(!gameplay_ts.contains("AdminReset"));
742
743        // UnitDestroyed belongs to both surfaces
744        assert!(gameplay_ts.contains("export interface UnitDestroyed"));
745        assert!(authority_ts.contains("export interface UnitDestroyed"));
746
747        // Headers include surface name
748        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        // No section headers
758        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        // Each module is self-contained
772        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        // Single-surface output must be byte-identical to generate_typescript()
783        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        // Intentionally out-of-order with a duplicate
790        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    // -- T6: End-to-end multi-surface proof --
800
801    #[test]
802    fn multi_surface_end_to_end_isolation() {
803        let manifest = sample_multi_surface_manifest();
804
805        // TS codegen: each surface gets its own module
806        let ts_modules = generate_all_surface_typescripts(&manifest);
807        assert_eq!(ts_modules.len(), 2);
808
809        // Descriptor codegen: each surface gets its own descriptor set
810        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        // Authority surface: AdminReset + UnitDestroyed
827        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        // Gameplay surface: SpawnUnit + UnitDestroyed
833        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        // Descriptor names match TS interface names
839        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}