Skip to main content

hyperstack_interpreter/
typescript.rs

1use crate::ast::*;
2use std::collections::{BTreeMap, BTreeSet, HashSet};
3
4/// Output structure for TypeScript generation
5#[derive(Debug, Clone)]
6pub struct TypeScriptOutput {
7    pub interfaces: String,
8    pub stack_definition: String,
9    pub imports: String,
10    pub schema_names: Vec<String>,
11}
12
13impl TypeScriptOutput {
14    pub fn full_file(&self) -> String {
15        format!(
16            "{}\n\n{}\n\n{}",
17            self.imports, self.interfaces, self.stack_definition
18        )
19    }
20}
21
22/// Configuration for TypeScript generation
23#[derive(Debug, Clone)]
24pub struct TypeScriptConfig {
25    pub package_name: String,
26    pub generate_helpers: bool,
27    pub interface_prefix: String,
28    pub export_const_name: String,
29    /// WebSocket URL for the stack. If None, generates a placeholder comment.
30    pub url: Option<String>,
31}
32
33impl Default for TypeScriptConfig {
34    fn default() -> Self {
35        Self {
36            package_name: "hyperstack-react".to_string(),
37            generate_helpers: true,
38            interface_prefix: "".to_string(),
39            export_const_name: "STACK".to_string(),
40            url: None,
41        }
42    }
43}
44
45/// Trait for generating TypeScript code from AST components
46pub trait TypeScriptGenerator {
47    fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
48}
49
50/// Trait for generating TypeScript interfaces
51pub trait TypeScriptInterfaceGenerator {
52    fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
53}
54
55/// Trait for generating TypeScript type mappings
56pub trait TypeScriptTypeMapper {
57    fn to_typescript_type(&self) -> String;
58}
59
60/// Main TypeScript compiler for stream specs
61pub struct TypeScriptCompiler<S> {
62    spec: TypedStreamSpec<S>,
63    entity_name: String,
64    config: TypeScriptConfig,
65    idl: Option<serde_json::Value>, // IDL for enum type generation
66    handlers_json: Option<serde_json::Value>, // Raw handlers for event interface generation
67    views: Vec<ViewDef>,            // View definitions for derived views
68    already_emitted_types: HashSet<String>,
69}
70
71impl<S> TypeScriptCompiler<S> {
72    pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
73        Self {
74            spec,
75            entity_name,
76            config: TypeScriptConfig::default(),
77            idl: None,
78            handlers_json: None,
79            views: Vec::new(),
80            already_emitted_types: HashSet::new(),
81        }
82    }
83
84    pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
85        self.config = config;
86        self
87    }
88
89    pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
90        self.idl = idl;
91        self
92    }
93
94    pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
95        self.handlers_json = handlers;
96        self
97    }
98
99    pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
100        self.views = views;
101        self
102    }
103
104    pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
105        self.already_emitted_types = types;
106        self
107    }
108
109    pub fn compile(&self) -> TypeScriptOutput {
110        let imports = self.generate_imports();
111        let interfaces = self.generate_interfaces();
112        let schema_output = self.generate_schemas();
113        let combined_interfaces = if schema_output.definitions.is_empty() {
114            interfaces
115        } else if interfaces.is_empty() {
116            schema_output.definitions.clone()
117        } else {
118            format!("{}\n\n{}", interfaces, schema_output.definitions)
119        };
120        let stack_definition = self.generate_stack_definition();
121
122        TypeScriptOutput {
123            imports,
124            interfaces: combined_interfaces,
125            stack_definition,
126            schema_names: schema_output.names,
127        }
128    }
129
130    fn generate_imports(&self) -> String {
131        "import { z } from 'zod';".to_string()
132    }
133
134    fn generate_view_helpers(&self) -> String {
135        r#"// ============================================================================
136// View Definition Types (framework-agnostic)
137// ============================================================================
138
139/** View definition with embedded entity type */
140export interface ViewDef<T, TMode extends 'state' | 'list'> {
141  readonly mode: TMode;
142  readonly view: string;
143  /** Phantom field for type inference - not present at runtime */
144  readonly _entity?: T;
145}
146
147/** Helper to create typed state view definitions (keyed lookups) */
148function stateView<T>(view: string): ViewDef<T, 'state'> {
149  return { mode: 'state', view } as const;
150}
151
152/** Helper to create typed list view definitions (collections) */
153function listView<T>(view: string): ViewDef<T, 'list'> {
154  return { mode: 'list', view } as const;
155}"#
156        .to_string()
157    }
158
159    fn generate_interfaces(&self) -> String {
160        let mut interfaces = Vec::new();
161        let mut processed_types = HashSet::new();
162        let all_sections = self.collect_interface_sections();
163
164        // Deduplicate fields within each section and generate interfaces
165        // Skip root section - its fields will be flattened into main entity interface
166        for (section_name, fields) in all_sections {
167            if !is_root_section(&section_name) && processed_types.insert(section_name.clone()) {
168                let deduplicated_fields = self.deduplicate_fields(fields);
169                let interface =
170                    self.generate_interface_from_fields(&section_name, &deduplicated_fields);
171                interfaces.push(interface);
172            }
173        }
174
175        // Generate main entity interface
176        let main_interface = self.generate_main_entity_interface();
177        interfaces.push(main_interface);
178
179        let nested_interfaces = self.generate_nested_interfaces();
180        interfaces.extend(nested_interfaces);
181
182        let builtin_interfaces = self.generate_builtin_resolver_interfaces();
183        interfaces.extend(builtin_interfaces);
184
185        if self.has_event_types() {
186            interfaces.push(self.generate_event_wrapper_interface());
187        }
188
189        interfaces.join("\n\n")
190    }
191
192    fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
193        let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
194
195        // Collect all interface sections from all handlers
196        for handler in &self.spec.handlers {
197            let interface_sections = self.extract_interface_sections_from_handler(handler);
198
199            for (section_name, mut fields) in interface_sections {
200                all_sections
201                    .entry(section_name)
202                    .or_default()
203                    .append(&mut fields);
204            }
205        }
206
207        // Add unmapped fields from spec.sections ONCE (not per handler)
208        // These are fields without #[map] or #[event] attributes
209        self.add_unmapped_fields(&mut all_sections);
210
211        all_sections
212    }
213
214    fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
215        let mut seen = HashSet::new();
216        let mut unique_fields = Vec::new();
217
218        // Sort fields by name for consistent output
219        fields.sort_by(|a, b| a.name.cmp(&b.name));
220
221        for field in fields {
222            if seen.insert(field.name.clone()) {
223                unique_fields.push(field);
224            }
225        }
226
227        unique_fields
228    }
229
230    fn extract_interface_sections_from_handler(
231        &self,
232        handler: &TypedHandlerSpec<S>,
233    ) -> BTreeMap<String, Vec<TypeScriptField>> {
234        let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
235
236        for mapping in &handler.mappings {
237            if !mapping.emit {
238                continue;
239            }
240            let parts: Vec<&str> = mapping.target_path.split('.').collect();
241
242            if parts.len() > 1 {
243                let section_name = parts[0];
244                let field_name = parts[1];
245
246                let ts_field = TypeScriptField {
247                    name: field_name.to_string(),
248                    ts_type: self.mapping_to_typescript_type(mapping),
249                    optional: self.is_field_optional(mapping),
250                    description: None,
251                };
252
253                sections
254                    .entry(section_name.to_string())
255                    .or_default()
256                    .push(ts_field);
257            } else {
258                let ts_field = TypeScriptField {
259                    name: mapping.target_path.clone(),
260                    ts_type: self.mapping_to_typescript_type(mapping),
261                    optional: self.is_field_optional(mapping),
262                    description: None,
263                };
264
265                sections
266                    .entry("Root".to_string())
267                    .or_default()
268                    .push(ts_field);
269            }
270        }
271
272        sections
273    }
274
275    fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
276        // NEW: Enhanced approach using AST type information if available
277        if !self.spec.sections.is_empty() {
278            // Use type information from the enhanced AST
279            for section in &self.spec.sections {
280                let section_fields = sections.entry(section.name.clone()).or_default();
281
282                for field_info in &section.fields {
283                    if !field_info.emit {
284                        continue;
285                    }
286                    // Check if field is already mapped
287                    let already_exists = section_fields
288                        .iter()
289                        .any(|f| f.name == field_info.field_name);
290
291                    if !already_exists {
292                        section_fields.push(TypeScriptField {
293                            name: field_info.field_name.clone(),
294                            ts_type: self.field_type_info_to_typescript(field_info),
295                            optional: field_info.is_optional,
296                            description: None,
297                        });
298                    }
299                }
300            }
301        } else {
302            // FALLBACK: Use field mappings from spec if sections aren't available yet
303            for (field_path, field_type_info) in &self.spec.field_mappings {
304                if !field_type_info.emit {
305                    continue;
306                }
307                let parts: Vec<&str> = field_path.split('.').collect();
308                if parts.len() > 1 {
309                    let section_name = parts[0];
310                    let field_name = parts[1];
311
312                    let section_fields = sections.entry(section_name.to_string()).or_default();
313
314                    let already_exists = section_fields.iter().any(|f| f.name == field_name);
315
316                    if !already_exists {
317                        section_fields.push(TypeScriptField {
318                            name: field_name.to_string(),
319                            ts_type: self.base_type_to_typescript(
320                                &field_type_info.base_type,
321                                field_type_info.is_array,
322                            ),
323                            optional: field_type_info.is_optional,
324                            description: None,
325                        });
326                    }
327                }
328            }
329        }
330    }
331
332    fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
333        let interface_name = self.section_interface_name(name);
334
335        // All fields are optional (?) since we receive patches - field may not yet exist
336        // For spec-optional fields, we use `T | null` to distinguish "explicitly null" from "not received"
337        let field_definitions: Vec<String> = fields
338            .iter()
339            .map(|field| {
340                let ts_type = if field.optional {
341                    // Spec-optional: can be explicitly null
342                    format!("{} | null", field.ts_type)
343                } else {
344                    field.ts_type.clone()
345                };
346                format!("  {}?: {};", field.name, ts_type)
347            })
348            .collect();
349
350        format!(
351            "export interface {} {{\n{}\n}}",
352            interface_name,
353            field_definitions.join("\n")
354        )
355    }
356
357    fn section_interface_name(&self, name: &str) -> String {
358        if name == "Root" {
359            format!(
360                "{}{}",
361                self.config.interface_prefix,
362                to_pascal_case(&self.entity_name)
363            )
364        } else {
365            // Create compound names like GameEvents, GameStatus, etc.
366            // Extract the base name (e.g., "Game" from "TestGame" or "SettlementGame")
367            let base_name = if self.entity_name.contains("Game") {
368                "Game"
369            } else {
370                &self.entity_name
371            };
372            format!(
373                "{}{}{}",
374                self.config.interface_prefix,
375                base_name,
376                to_pascal_case(name)
377            )
378        }
379    }
380
381    fn generate_main_entity_interface(&self) -> String {
382        let entity_name = to_pascal_case(&self.entity_name);
383
384        // Extract all top-level sections from the handlers
385        let mut sections = BTreeMap::new();
386
387        for handler in &self.spec.handlers {
388            for mapping in &handler.mappings {
389                if !mapping.emit {
390                    continue;
391                }
392                let parts: Vec<&str> = mapping.target_path.split('.').collect();
393                if parts.len() > 1 {
394                    sections.insert(parts[0], true);
395                }
396            }
397        }
398
399        if !self.spec.sections.is_empty() {
400            for section in &self.spec.sections {
401                if section.fields.iter().any(|field| field.emit) {
402                    sections.insert(&section.name, true);
403                }
404            }
405        } else {
406            for mapping in &self.spec.handlers {
407                for field_mapping in &mapping.mappings {
408                    if !field_mapping.emit {
409                        continue;
410                    }
411                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
412                    if parts.len() > 1 {
413                        sections.insert(parts[0], true);
414                    }
415                }
416            }
417        }
418
419        let mut fields = Vec::new();
420
421        // Add non-root sections as nested interface references
422        // All fields are optional since we receive patches
423        for section in sections.keys() {
424            if !is_root_section(section) {
425                let base_name = if self.entity_name.contains("Game") {
426                    "Game"
427                } else {
428                    &self.entity_name
429                };
430                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
431                // Keep section field names as-is (snake_case from AST)
432                fields.push(format!("  {}?: {};", section, section_interface_name));
433            }
434        }
435
436        // Flatten root section fields directly into main interface
437        // All fields are optional (?) since we receive patches
438        for section in &self.spec.sections {
439            if is_root_section(&section.name) {
440                for field in &section.fields {
441                    if !field.emit {
442                        continue;
443                    }
444                    let base_ts_type = self.field_type_info_to_typescript(field);
445                    let ts_type = if field.is_optional {
446                        format!("{} | null", base_ts_type)
447                    } else {
448                        base_ts_type
449                    };
450                    fields.push(format!("  {}?: {};", field.field_name, ts_type));
451                }
452            }
453        }
454
455        if fields.is_empty() {
456            fields.push("  // Generated interface - extend as needed".to_string());
457        }
458
459        format!(
460            "export interface {} {{\n{}\n}}",
461            entity_name,
462            fields.join("\n")
463        )
464    }
465
466    fn generate_schemas(&self) -> SchemaOutput {
467        let mut definitions = Vec::new();
468        let mut names = Vec::new();
469        let mut seen = HashSet::new();
470
471        let mut push_schema = |schema_name: String, definition: String| {
472            if seen.insert(schema_name.clone()) {
473                names.push(schema_name);
474                definitions.push(definition);
475            }
476        };
477
478        for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
479            push_schema(schema_name, definition);
480        }
481
482        if self.has_event_types() {
483            push_schema(
484                "EventWrapperSchema".to_string(),
485                self.generate_event_wrapper_schema(),
486            );
487        }
488
489        for (schema_name, definition) in self.generate_resolved_type_schemas() {
490            push_schema(schema_name, definition);
491        }
492
493        for (schema_name, definition) in self.generate_event_schemas() {
494            push_schema(schema_name, definition);
495        }
496
497        for (schema_name, definition) in self.generate_idl_enum_schemas() {
498            push_schema(schema_name, definition);
499        }
500
501        let all_sections = self.collect_interface_sections();
502
503        for (section_name, fields) in &all_sections {
504            if is_root_section(section_name) {
505                continue;
506            }
507            let deduplicated_fields = self.deduplicate_fields(fields.clone());
508            let interface_name = self.section_interface_name(section_name);
509            let schema_definition =
510                self.generate_schema_for_fields(&interface_name, &deduplicated_fields, false);
511            push_schema(format!("{}Schema", interface_name), schema_definition);
512        }
513
514        let entity_name = to_pascal_case(&self.entity_name);
515        let main_fields = self.collect_main_entity_fields();
516        let entity_schema = self.generate_schema_for_fields(&entity_name, &main_fields, false);
517        push_schema(format!("{}Schema", entity_name), entity_schema);
518
519        let completed_schema = self.generate_completed_entity_schema(&entity_name);
520        push_schema(format!("{}CompletedSchema", entity_name), completed_schema);
521
522        SchemaOutput {
523            definitions: definitions.join("\n\n"),
524            names,
525        }
526    }
527
528    fn generate_event_wrapper_schema(&self) -> String {
529        r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
530  timestamp: z.number(),
531  data,
532  slot: z.number().optional(),
533  signature: z.string().optional(),
534});"#
535            .to_string()
536    }
537
538    fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
539        let mut schemas = Vec::new();
540        let registry = crate::resolvers::builtin_resolver_registry();
541
542        for resolver in registry.definitions() {
543            if self.uses_builtin_type(resolver.output_type())
544                && !self.already_emitted_types.contains(resolver.output_type())
545            {
546                if let Some(schema) = resolver.typescript_schema() {
547                    schemas.push((schema.name.to_string(), schema.definition.to_string()));
548                }
549            }
550        }
551
552        schemas
553    }
554
555    fn uses_builtin_type(&self, type_name: &str) -> bool {
556        for section in &self.spec.sections {
557            for field in &section.fields {
558                if field.inner_type.as_deref() == Some(type_name) {
559                    return true;
560                }
561            }
562        }
563        false
564    }
565
566    fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
567        let mut interfaces = Vec::new();
568        let registry = crate::resolvers::builtin_resolver_registry();
569
570        for resolver in registry.definitions() {
571            if self.uses_builtin_type(resolver.output_type())
572                && !self.already_emitted_types.contains(resolver.output_type())
573            {
574                if let Some(interface) = resolver.typescript_interface() {
575                    interfaces.push(interface.to_string());
576                }
577            }
578        }
579
580        interfaces
581    }
582
583    fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
584        let mut sections = BTreeMap::new();
585
586        for handler in &self.spec.handlers {
587            for mapping in &handler.mappings {
588                if !mapping.emit {
589                    continue;
590                }
591                let parts: Vec<&str> = mapping.target_path.split('.').collect();
592                if parts.len() > 1 {
593                    sections.insert(parts[0], true);
594                }
595            }
596        }
597
598        if !self.spec.sections.is_empty() {
599            for section in &self.spec.sections {
600                if section.fields.iter().any(|field| field.emit) {
601                    sections.insert(&section.name, true);
602                }
603            }
604        } else {
605            for mapping in &self.spec.handlers {
606                for field_mapping in &mapping.mappings {
607                    if !field_mapping.emit {
608                        continue;
609                    }
610                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
611                    if parts.len() > 1 {
612                        sections.insert(parts[0], true);
613                    }
614                }
615            }
616        }
617
618        let mut fields = Vec::new();
619
620        for section in sections.keys() {
621            if !is_root_section(section) {
622                let base_name = if self.entity_name.contains("Game") {
623                    "Game"
624                } else {
625                    &self.entity_name
626                };
627                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
628                fields.push(TypeScriptField {
629                    name: section.to_string(),
630                    ts_type: section_interface_name,
631                    optional: false,
632                    description: None,
633                });
634            }
635        }
636
637        for section in &self.spec.sections {
638            if is_root_section(&section.name) {
639                for field in &section.fields {
640                    if !field.emit {
641                        continue;
642                    }
643                    fields.push(TypeScriptField {
644                        name: field.field_name.clone(),
645                        ts_type: self.field_type_info_to_typescript(field),
646                        optional: field.is_optional,
647                        description: None,
648                    });
649                }
650            }
651        }
652
653        fields
654    }
655
656    fn generate_schema_for_fields(
657        &self,
658        name: &str,
659        fields: &[TypeScriptField],
660        required: bool,
661    ) -> String {
662        let mut field_definitions = Vec::new();
663
664        for field in fields {
665            let base_schema = self.typescript_type_to_zod(&field.ts_type);
666            let schema = if required {
667                base_schema
668            } else {
669                let with_nullable = if field.optional {
670                    format!("{}.nullable()", base_schema)
671                } else {
672                    base_schema
673                };
674                format!("{}.optional()", with_nullable)
675            };
676
677            field_definitions.push(format!("  {}: {},", field.name, schema));
678        }
679
680        format!(
681            "export const {}Schema = z.object({{\n{}\n}});",
682            name,
683            field_definitions.join("\n")
684        )
685    }
686
687    fn generate_completed_entity_schema(&self, entity_name: &str) -> String {
688        let main_fields = self.collect_main_entity_fields();
689        self.generate_schema_for_fields(&format!("{}Completed", entity_name), &main_fields, true)
690    }
691
692    fn generate_resolved_type_schemas(&self) -> Vec<(String, String)> {
693        let mut schemas = Vec::new();
694        let mut generated_types = HashSet::new();
695
696        for section in &self.spec.sections {
697            for field_info in &section.fields {
698                if let Some(resolved) = &field_info.resolved_type {
699                    let type_name = to_pascal_case(&resolved.type_name);
700
701                    if !generated_types.insert(type_name.clone()) {
702                        continue;
703                    }
704
705                    if resolved.is_enum {
706                        let variants: Vec<String> = resolved
707                            .enum_variants
708                            .iter()
709                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
710                            .collect();
711                        let schema = if variants.is_empty() {
712                            format!("export const {}Schema = z.string();", type_name)
713                        } else {
714                            format!(
715                                "export const {}Schema = z.enum([{}]);",
716                                type_name,
717                                variants.join(", ")
718                            )
719                        };
720                        schemas.push((format!("{}Schema", type_name), schema));
721                        continue;
722                    }
723
724                    let mut field_definitions = Vec::new();
725                    for field in &resolved.fields {
726                        let base = self.resolved_field_to_zod(field);
727                        let schema = if field.is_optional {
728                            format!("{}.nullable().optional()", base)
729                        } else {
730                            format!("{}.optional()", base)
731                        };
732                        field_definitions.push(format!("  {}: {},", field.field_name, schema));
733                    }
734
735                    let schema = format!(
736                        "export const {}Schema = z.object({{\n{}\n}});",
737                        type_name,
738                        field_definitions.join("\n")
739                    );
740                    schemas.push((format!("{}Schema", type_name), schema));
741                }
742            }
743        }
744
745        schemas
746    }
747
748    fn generate_event_schemas(&self) -> Vec<(String, String)> {
749        let mut schemas = Vec::new();
750        let mut generated_types = HashSet::new();
751
752        let handlers = match &self.handlers_json {
753            Some(h) => h.as_array(),
754            None => return schemas,
755        };
756
757        let handlers_array = match handlers {
758            Some(arr) => arr,
759            None => return schemas,
760        };
761
762        for handler in handlers_array {
763            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
764                for mapping in mappings {
765                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
766                        if target_path.contains(".events.") || target_path.starts_with("events.") {
767                            if let Some(source) = mapping.get("source") {
768                                if let Some(event_data) = self.extract_event_data(source) {
769                                    if let Some(handler_source) = handler.get("source") {
770                                        if let Some(instruction_name) =
771                                            self.extract_instruction_name(handler_source)
772                                        {
773                                            let event_field_name =
774                                                target_path.split('.').next_back().unwrap_or("");
775                                            let interface_name = format!(
776                                                "{}Event",
777                                                to_pascal_case(event_field_name)
778                                            );
779
780                                            if generated_types.insert(interface_name.clone()) {
781                                                if let Some(schema) = self
782                                                    .generate_event_schema_from_idl(
783                                                        &interface_name,
784                                                        &instruction_name,
785                                                        &event_data,
786                                                    )
787                                                {
788                                                    schemas.push((
789                                                        format!("{}Schema", interface_name),
790                                                        schema,
791                                                    ));
792                                                }
793                                            }
794                                        }
795                                    }
796                                }
797                            }
798                        }
799                    }
800                }
801            }
802        }
803
804        schemas
805    }
806
807    fn generate_event_schema_from_idl(
808        &self,
809        interface_name: &str,
810        rust_instruction_name: &str,
811        captured_fields: &[(String, Option<String>)],
812    ) -> Option<String> {
813        if captured_fields.is_empty() {
814            return Some(format!(
815                "export const {}Schema = z.object({{}});",
816                interface_name
817            ));
818        }
819
820        let idl_value = self.idl.as_ref()?;
821        let instructions = idl_value.get("instructions")?.as_array()?;
822
823        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
824        let args = instruction.get("args")?.as_array()?;
825
826        let mut fields = Vec::new();
827        for (field_name, transform) in captured_fields {
828            for arg in args {
829                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
830                    if arg_name == field_name {
831                        if let Some(arg_type) = arg.get("type") {
832                            let ts_type =
833                                self.idl_type_to_typescript(arg_type, transform.as_deref());
834                            let schema = self.typescript_type_to_zod(&ts_type);
835                            fields.push(format!("  {}: {},", field_name, schema));
836                        }
837                        break;
838                    }
839                }
840            }
841        }
842
843        Some(format!(
844            "export const {}Schema = z.object({{\n{}\n}});",
845            interface_name,
846            fields.join("\n")
847        ))
848    }
849
850    fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
851        let mut schemas = Vec::new();
852        let mut generated_types = self.already_emitted_types.clone();
853
854        let idl_value = match &self.idl {
855            Some(idl) => idl,
856            None => return schemas,
857        };
858
859        let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
860            Some(types) => types,
861            None => return schemas,
862        };
863
864        for type_def in types_array {
865            if let (Some(type_name), Some(type_obj)) = (
866                type_def.get("name").and_then(|v| v.as_str()),
867                type_def.get("type").and_then(|v| v.as_object()),
868            ) {
869                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
870                    if !generated_types.insert(type_name.to_string()) {
871                        continue;
872                    }
873                    if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
874                        let variant_names: Vec<String> = variants
875                            .iter()
876                            .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
877                            .map(|s| format!("\"{}\"", to_pascal_case(s)))
878                            .collect();
879
880                        let interface_name = to_pascal_case(type_name);
881                        let schema = if variant_names.is_empty() {
882                            format!("export const {}Schema = z.string();", interface_name)
883                        } else {
884                            format!(
885                                "export const {}Schema = z.enum([{}]);",
886                                interface_name,
887                                variant_names.join(", ")
888                            )
889                        };
890                        schemas.push((format!("{}Schema", interface_name), schema));
891                    }
892                }
893            }
894        }
895
896        schemas
897    }
898
899    fn typescript_type_to_zod(&self, ts_type: &str) -> String {
900        let trimmed = ts_type.trim();
901
902        if let Some(inner) = trimmed.strip_suffix("[]") {
903            return format!("z.array({})", self.typescript_type_to_zod(inner));
904        }
905
906        if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
907            if let Some(inner) = inner.strip_suffix('>') {
908                return format!("EventWrapperSchema({})", self.typescript_type_to_zod(inner));
909            }
910        }
911
912        match trimmed {
913            "string" => "z.string()".to_string(),
914            "number" => "z.number()".to_string(),
915            "boolean" => "z.boolean()".to_string(),
916            "any" => "z.any()".to_string(),
917            "Record<string, any>" => "z.record(z.any())".to_string(),
918            _ => format!("{}Schema", trimmed),
919        }
920    }
921
922    fn resolved_field_to_zod(&self, field: &ResolvedField) -> String {
923        let base = self.base_type_to_zod(&field.base_type);
924        if field.is_array {
925            format!("z.array({})", base)
926        } else {
927            base
928        }
929    }
930
931    fn generate_stack_definition(&self) -> String {
932        let stack_name = to_kebab_case(&self.entity_name);
933        let entity_pascal = to_pascal_case(&self.entity_name);
934        let export_name = format!(
935            "{}_{}",
936            self.entity_name.to_uppercase(),
937            self.config.export_const_name
938        );
939
940        let view_helpers = self.generate_view_helpers();
941        let derived_views = self.generate_derived_view_entries();
942        let schema_names = self.generate_schemas().names;
943        let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
944        for name in schema_names {
945            unique_schemas.insert(name);
946        }
947        let schemas_block = if unique_schemas.is_empty() {
948            String::new()
949        } else {
950            let schema_entries: Vec<String> = unique_schemas
951                .iter()
952                .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
953                .collect();
954            format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
955        };
956
957        // Generate URL line - either actual URL or placeholder comment
958        let url_line = match &self.config.url {
959            Some(url) => format!("  url: '{}',", url),
960            None => "  // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
961        };
962
963        format!(
964            r#"{}
965
966// ============================================================================
967// Stack Definition
968// ============================================================================
969
970/** Stack definition for {} */
971export const {} = {{
972  name: '{}',
973{}
974  views: {{
975    {}: {{
976      state: stateView<{}>('{}/state'),
977      list: listView<{}>('{}/list'),{}
978    }},
979  }},{}
980}} as const;
981
982/** Type alias for the stack */
983export type {}Stack = typeof {};
984
985/** Default export for convenience */
986export default {};"#,
987            view_helpers,
988            entity_pascal,
989            export_name,
990            stack_name,
991            url_line,
992            self.entity_name,
993            entity_pascal,
994            self.entity_name,
995            entity_pascal,
996            self.entity_name,
997            derived_views,
998            schemas_block,
999            entity_pascal,
1000            export_name,
1001            export_name
1002        )
1003    }
1004
1005    fn generate_derived_view_entries(&self) -> String {
1006        let derived_views: Vec<&ViewDef> = self
1007            .views
1008            .iter()
1009            .filter(|v| {
1010                !v.id.ends_with("/state")
1011                    && !v.id.ends_with("/list")
1012                    && v.id.starts_with(&self.entity_name)
1013            })
1014            .collect();
1015
1016        if derived_views.is_empty() {
1017            return String::new();
1018        }
1019
1020        let entity_pascal = to_pascal_case(&self.entity_name);
1021        let mut entries = Vec::new();
1022
1023        for view in derived_views {
1024            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1025
1026            entries.push(format!(
1027                "\n      {}: listView<{}>('{}'),",
1028                view_name, entity_pascal, view.id
1029            ));
1030        }
1031
1032        entries.join("")
1033    }
1034
1035    fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1036        // First, try to resolve from AST field mappings
1037        if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1038            let ts_type = self.field_type_info_to_typescript(field_info);
1039
1040            // If it's an Append strategy, wrap in array
1041            if matches!(mapping.population, PopulationStrategy::Append) {
1042                return if ts_type.ends_with("[]") {
1043                    ts_type
1044                } else {
1045                    format!("{}[]", ts_type)
1046                };
1047            }
1048
1049            return ts_type;
1050        }
1051
1052        // Fallback to legacy inference
1053        match &mapping.population {
1054            PopulationStrategy::Append => {
1055                // For arrays, try to infer the element type
1056                match &mapping.source {
1057                    MappingSource::AsEvent { .. } => "any[]".to_string(),
1058                    _ => "any[]".to_string(),
1059                }
1060            }
1061            _ => {
1062                // Infer type from source and field name
1063                let base_type = match &mapping.source {
1064                    MappingSource::FromSource { .. } => {
1065                        self.infer_type_from_field_name(&mapping.target_path)
1066                    }
1067                    MappingSource::Constant(value) => value_to_typescript_type(value),
1068                    MappingSource::AsEvent { .. } => "any".to_string(),
1069                    _ => "any".to_string(),
1070                };
1071
1072                // Apply transformations to type
1073                if let Some(transform) = &mapping.transform {
1074                    match transform {
1075                        Transformation::HexEncode | Transformation::HexDecode => {
1076                            "string".to_string()
1077                        }
1078                        Transformation::Base58Encode | Transformation::Base58Decode => {
1079                            "string".to_string()
1080                        }
1081                        Transformation::ToString => "string".to_string(),
1082                        Transformation::ToNumber => "number".to_string(),
1083                    }
1084                } else {
1085                    base_type
1086                }
1087            }
1088        }
1089    }
1090
1091    fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1092        if let Some(resolved) = &field_info.resolved_type {
1093            let interface_name = self.resolved_type_to_interface_name(resolved);
1094
1095            let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1096            {
1097                format!("EventWrapper<{}>", interface_name)
1098            } else {
1099                interface_name
1100            };
1101
1102            let with_array = if field_info.is_array {
1103                format!("{}[]", base_type)
1104            } else {
1105                base_type
1106            };
1107
1108            return with_array;
1109        }
1110
1111        if let Some(inner_type) = &field_info.inner_type {
1112            if is_builtin_resolver_type(inner_type) {
1113                return inner_type.clone();
1114            }
1115        }
1116
1117        if field_info.base_type == BaseType::Any
1118            || (field_info.base_type == BaseType::Array
1119                && field_info.inner_type.as_deref() == Some("Value"))
1120        {
1121            if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1122                return if field_info.is_array {
1123                    format!("{}[]", event_type)
1124                } else if field_info.is_optional {
1125                    format!("{} | null", event_type)
1126                } else {
1127                    event_type
1128                };
1129            }
1130        }
1131
1132        self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
1133    }
1134
1135    /// Find the generated event interface name for a given field
1136    fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1137        // Use the raw JSON handlers if available
1138        let handlers = self.handlers_json.as_ref()?.as_array()?;
1139
1140        // Look through handlers to find event mappings for this field
1141        for handler in handlers {
1142            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1143                for mapping in mappings {
1144                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1145                        // Check if this mapping targets our field (e.g., "events.created")
1146                        let target_parts: Vec<&str> = target_path.split('.').collect();
1147                        if let Some(target_field) = target_parts.last() {
1148                            if *target_field == field_name {
1149                                // Check if this is an event mapping
1150                                if let Some(source) = mapping.get("source") {
1151                                    if self.extract_event_data(source).is_some() {
1152                                        // Generate the interface name (e.g., "created" -> "CreatedEvent")
1153                                        return Some(format!(
1154                                            "{}Event",
1155                                            to_pascal_case(field_name)
1156                                        ));
1157                                    }
1158                                }
1159                            }
1160                        }
1161                    }
1162                }
1163            }
1164        }
1165        None
1166    }
1167
1168    /// Generate TypeScript interface name from resolved type
1169    fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1170        to_pascal_case(&resolved.type_name)
1171    }
1172
1173    /// Generate nested interfaces for all resolved types in the AST
1174    fn generate_nested_interfaces(&self) -> Vec<String> {
1175        let mut interfaces = Vec::new();
1176        let mut generated_types = self.already_emitted_types.clone();
1177
1178        // Collect all resolved types from all sections
1179        for section in &self.spec.sections {
1180            for field_info in &section.fields {
1181                if let Some(resolved) = &field_info.resolved_type {
1182                    let type_name = resolved.type_name.clone();
1183
1184                    // Only generate each type once
1185                    if generated_types.insert(type_name) {
1186                        let interface = self.generate_interface_for_resolved_type(resolved);
1187                        interfaces.push(interface);
1188                    }
1189                }
1190            }
1191        }
1192
1193        // Generate event interfaces from instruction handlers
1194        interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1195
1196        // Also generate all enum types from the IDL (even if not directly referenced)
1197        if let Some(idl_value) = &self.idl {
1198            if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1199                for type_def in types_array {
1200                    if let (Some(type_name), Some(type_obj)) = (
1201                        type_def.get("name").and_then(|v| v.as_str()),
1202                        type_def.get("type").and_then(|v| v.as_object()),
1203                    ) {
1204                        if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1205                            // Only generate if not already generated
1206                            if generated_types.insert(type_name.to_string()) {
1207                                if let Some(variants) =
1208                                    type_obj.get("variants").and_then(|v| v.as_array())
1209                                {
1210                                    let variant_names: Vec<String> = variants
1211                                        .iter()
1212                                        .filter_map(|v| {
1213                                            v.get("name")
1214                                                .and_then(|n| n.as_str())
1215                                                .map(|s| s.to_string())
1216                                        })
1217                                        .collect();
1218
1219                                    if !variant_names.is_empty() {
1220                                        let interface_name = to_pascal_case(type_name);
1221                                        let variant_strings: Vec<String> = variant_names
1222                                            .iter()
1223                                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
1224                                            .collect();
1225
1226                                        let enum_type = format!(
1227                                            "export type {} = {};",
1228                                            interface_name,
1229                                            variant_strings.join(" | ")
1230                                        );
1231                                        interfaces.push(enum_type);
1232                                    }
1233                                }
1234                            }
1235                        }
1236                    }
1237                }
1238            }
1239        }
1240
1241        interfaces
1242    }
1243
1244    /// Generate TypeScript interfaces for event types from instruction handlers
1245    fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1246        let mut interfaces = Vec::new();
1247
1248        // Use the raw JSON handlers if available
1249        let handlers = match &self.handlers_json {
1250            Some(h) => h.as_array(),
1251            None => return interfaces,
1252        };
1253
1254        let handlers_array = match handlers {
1255            Some(arr) => arr,
1256            None => return interfaces,
1257        };
1258
1259        // Look through handlers to find instruction-based event mappings
1260        for handler in handlers_array {
1261            // Check if this handler has event mappings
1262            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1263                for mapping in mappings {
1264                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1265                        // Check if the target is an event field (contains ".events." or starts with "events.")
1266                        if target_path.contains(".events.") || target_path.starts_with("events.") {
1267                            // Check if the source is AsEvent
1268                            if let Some(source) = mapping.get("source") {
1269                                if let Some(event_data) = self.extract_event_data(source) {
1270                                    // Extract instruction name from handler source
1271                                    if let Some(handler_source) = handler.get("source") {
1272                                        if let Some(instruction_name) =
1273                                            self.extract_instruction_name(handler_source)
1274                                        {
1275                                            // Generate interface name from target path (e.g., "events.created" -> "CreatedEvent")
1276                                            let event_field_name =
1277                                                target_path.split('.').next_back().unwrap_or("");
1278                                            let interface_name = format!(
1279                                                "{}Event",
1280                                                to_pascal_case(event_field_name)
1281                                            );
1282
1283                                            // Only generate once
1284                                            if generated_types.insert(interface_name.clone()) {
1285                                                if let Some(interface) = self
1286                                                    .generate_event_interface_from_idl(
1287                                                        &interface_name,
1288                                                        &instruction_name,
1289                                                        &event_data,
1290                                                    )
1291                                                {
1292                                                    interfaces.push(interface);
1293                                                }
1294                                            }
1295                                        }
1296                                    }
1297                                }
1298                            }
1299                        }
1300                    }
1301                }
1302            }
1303        }
1304
1305        interfaces
1306    }
1307
1308    /// Extract event field data from a mapping source
1309    fn extract_event_data(
1310        &self,
1311        source: &serde_json::Value,
1312    ) -> Option<Vec<(String, Option<String>)>> {
1313        if let Some(as_event) = source.get("AsEvent") {
1314            if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1315                let mut event_fields = Vec::new();
1316                for field in fields {
1317                    if let Some(from_source) = field.get("FromSource") {
1318                        if let Some(path) = from_source
1319                            .get("path")
1320                            .and_then(|p| p.get("segments"))
1321                            .and_then(|s| s.as_array())
1322                        {
1323                            // Get the last segment as the field name (e.g., ["data", "game_id"] -> "game_id")
1324                            if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1325                                let transform = from_source
1326                                    .get("transform")
1327                                    .and_then(|t| t.as_str())
1328                                    .map(|s| s.to_string());
1329                                event_fields.push((field_name.to_string(), transform));
1330                            }
1331                        }
1332                    }
1333                }
1334                return Some(event_fields);
1335            }
1336        }
1337        None
1338    }
1339
1340    /// Extract instruction name from handler source, returning the raw PascalCase name
1341    fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1342        if let Some(source_obj) = source.get("Source") {
1343            if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1344                let instruction_part =
1345                    crate::event_type_helpers::strip_event_type_suffix(type_name);
1346                return Some(instruction_part.to_string());
1347            }
1348        }
1349        None
1350    }
1351
1352    /// Find an instruction in the IDL by name, handling different naming conventions.
1353    /// IDLs may use snake_case (pumpfun: "admin_set_creator") or camelCase (ore: "claimSol").
1354    /// The input name comes from Rust types which are PascalCase ("AdminSetCreator", "ClaimSol").
1355    fn find_instruction_in_idl<'a>(
1356        &self,
1357        instructions: &'a [serde_json::Value],
1358        rust_name: &str,
1359    ) -> Option<&'a serde_json::Value> {
1360        let normalized_search = normalize_for_comparison(rust_name);
1361
1362        for instruction in instructions {
1363            if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1364                if normalize_for_comparison(idl_name) == normalized_search {
1365                    return Some(instruction);
1366                }
1367            }
1368        }
1369        None
1370    }
1371
1372    /// Generate a TypeScript interface for an event from IDL instruction data
1373    fn generate_event_interface_from_idl(
1374        &self,
1375        interface_name: &str,
1376        rust_instruction_name: &str,
1377        captured_fields: &[(String, Option<String>)],
1378    ) -> Option<String> {
1379        if captured_fields.is_empty() {
1380            return Some(format!("export interface {} {{}}", interface_name));
1381        }
1382
1383        let idl_value = self.idl.as_ref()?;
1384        let instructions = idl_value.get("instructions")?.as_array()?;
1385
1386        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1387        let args = instruction.get("args")?.as_array()?;
1388
1389        let mut fields = Vec::new();
1390        for (field_name, transform) in captured_fields {
1391            for arg in args {
1392                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1393                    if arg_name == field_name {
1394                        if let Some(arg_type) = arg.get("type") {
1395                            let ts_type =
1396                                self.idl_type_to_typescript(arg_type, transform.as_deref());
1397                            fields.push(format!("  {}: {};", field_name, ts_type));
1398                        }
1399                        break;
1400                    }
1401                }
1402            }
1403        }
1404
1405        if !fields.is_empty() {
1406            return Some(format!(
1407                "export interface {} {{\n{}\n}}",
1408                interface_name,
1409                fields.join("\n")
1410            ));
1411        }
1412
1413        None
1414    }
1415
1416    /// Convert an IDL type (from JSON) to TypeScript, considering transforms
1417    fn idl_type_to_typescript(
1418        &self,
1419        idl_type: &serde_json::Value,
1420        transform: Option<&str>,
1421    ) -> String {
1422        #![allow(clippy::only_used_in_recursion)]
1423        // If there's a HexEncode transform, the result is always a string
1424        if transform == Some("HexEncode") {
1425            return "string".to_string();
1426        }
1427
1428        // Handle different IDL type formats
1429        if let Some(type_str) = idl_type.as_str() {
1430            return match type_str {
1431                "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
1432                    "number".to_string()
1433                }
1434                "f32" | "f64" => "number".to_string(),
1435                "bool" => "boolean".to_string(),
1436                "string" => "string".to_string(),
1437                "pubkey" | "publicKey" => "string".to_string(),
1438                "bytes" => "string".to_string(),
1439                _ => "any".to_string(),
1440            };
1441        }
1442
1443        // Handle complex types (option, vec, etc.)
1444        if let Some(type_obj) = idl_type.as_object() {
1445            if let Some(option_type) = type_obj.get("option") {
1446                let inner = self.idl_type_to_typescript(option_type, None);
1447                return format!("{} | null", inner);
1448            }
1449            if let Some(vec_type) = type_obj.get("vec") {
1450                let inner = self.idl_type_to_typescript(vec_type, None);
1451                return format!("{}[]", inner);
1452            }
1453        }
1454
1455        "any".to_string()
1456    }
1457
1458    /// Generate a TypeScript interface from a resolved struct type
1459    fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1460        let interface_name = to_pascal_case(&resolved.type_name);
1461
1462        // Handle enums as TypeScript union types
1463        if resolved.is_enum {
1464            let variants: Vec<String> = resolved
1465                .enum_variants
1466                .iter()
1467                .map(|v| format!("\"{}\"", to_pascal_case(v)))
1468                .collect();
1469
1470            return format!("export type {} = {};", interface_name, variants.join(" | "));
1471        }
1472
1473        // Handle structs as interfaces
1474        // All fields are optional since we receive patches
1475        let fields: Vec<String> = resolved
1476            .fields
1477            .iter()
1478            .map(|field| {
1479                let base_ts_type = self.resolved_field_to_typescript(field);
1480                let ts_type = if field.is_optional {
1481                    format!("{} | null", base_ts_type)
1482                } else {
1483                    base_ts_type
1484                };
1485                format!("  {}?: {};", field.field_name, ts_type)
1486            })
1487            .collect();
1488
1489        format!(
1490            "export interface {} {{\n{}\n}}",
1491            interface_name,
1492            fields.join("\n")
1493        )
1494    }
1495
1496    /// Convert a resolved field to TypeScript type
1497    fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1498        let base_ts = self.base_type_to_typescript(&field.base_type, false);
1499
1500        if field.is_array {
1501            format!("{}[]", base_ts)
1502        } else {
1503            base_ts
1504        }
1505    }
1506
1507    /// Check if the spec has any event types
1508    fn has_event_types(&self) -> bool {
1509        for section in &self.spec.sections {
1510            for field_info in &section.fields {
1511                if let Some(resolved) = &field_info.resolved_type {
1512                    if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1513                        return true;
1514                    }
1515                }
1516            }
1517        }
1518        false
1519    }
1520
1521    /// Generate the EventWrapper interface
1522    fn generate_event_wrapper_interface(&self) -> String {
1523        r#"/**
1524 * Wrapper for event data that includes context metadata.
1525 * Events are automatically wrapped in this structure at runtime.
1526 */
1527export interface EventWrapper<T> {
1528  /** Unix timestamp when the event was processed */
1529  timestamp: number;
1530  /** The event-specific data */
1531  data: T;
1532  /** Optional blockchain slot number */
1533  slot?: number;
1534  /** Optional transaction signature */
1535  signature?: string;
1536}"#
1537        .to_string()
1538    }
1539
1540    fn infer_type_from_field_name(&self, field_name: &str) -> String {
1541        let lower_name = field_name.to_lowercase();
1542
1543        // Special case for event fields - these are typically Option<Value> and should be 'any'
1544        if lower_name.contains("events.") {
1545            // For fields in the events section, default to 'any' since they're typically Option<Value>
1546            return "any".to_string();
1547        }
1548
1549        // Common patterns for type inference
1550        if lower_name.contains("id")
1551            || lower_name.contains("count")
1552            || lower_name.contains("number")
1553            || lower_name.contains("timestamp")
1554            || lower_name.contains("time")
1555            || lower_name.contains("at")
1556            || lower_name.contains("volume")
1557            || lower_name.contains("amount")
1558            || lower_name.contains("ev")
1559            || lower_name.contains("fee")
1560            || lower_name.contains("payout")
1561            || lower_name.contains("distributed")
1562            || lower_name.contains("claimable")
1563            || lower_name.contains("total")
1564            || lower_name.contains("rate")
1565            || lower_name.contains("ratio")
1566            || lower_name.contains("current")
1567            || lower_name.contains("state")
1568        {
1569            "number".to_string()
1570        } else if lower_name.contains("status")
1571            || lower_name.contains("hash")
1572            || lower_name.contains("address")
1573            || lower_name.contains("key")
1574        {
1575            "string".to_string()
1576        } else {
1577            "any".to_string()
1578        }
1579    }
1580
1581    fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1582        // Most fields should be optional by default since we're dealing with Option<T> types
1583        match &mapping.source {
1584            // Constants are typically non-optional
1585            MappingSource::Constant(_) => false,
1586            // Events are typically optional (Option<Value>)
1587            MappingSource::AsEvent { .. } => true,
1588            // For source fields, default to optional since most Rust fields are Option<T>
1589            MappingSource::FromSource { .. } => true,
1590            // Other cases default to optional
1591            _ => true,
1592        }
1593    }
1594
1595    /// Convert language-agnostic base types to TypeScript types
1596    fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1597        let base_ts_type = match base_type {
1598            BaseType::Integer => "number",
1599            BaseType::Float => "number",
1600            BaseType::String => "string",
1601            BaseType::Boolean => "boolean",
1602            BaseType::Timestamp => "number", // Unix timestamps as numbers
1603            BaseType::Binary => "string",    // Base64 encoded strings
1604            BaseType::Pubkey => "string",    // Solana public keys as Base58 strings
1605            BaseType::Array => "any[]",      // Default array type
1606            BaseType::Object => "Record<string, any>", // Generic object
1607            BaseType::Any => "any",
1608        };
1609
1610        if is_array && !matches!(base_type, BaseType::Array) {
1611            format!("{}[]", base_ts_type)
1612        } else {
1613            base_ts_type.to_string()
1614        }
1615    }
1616
1617    /// Convert language-agnostic base types to Zod schema expressions
1618    fn base_type_to_zod(&self, base_type: &BaseType) -> String {
1619        match base_type {
1620            BaseType::Integer | BaseType::Float | BaseType::Timestamp => "z.number()".to_string(),
1621            BaseType::String | BaseType::Pubkey | BaseType::Binary => "z.string()".to_string(),
1622            BaseType::Boolean => "z.boolean()".to_string(),
1623            BaseType::Array => "z.array(z.any())".to_string(),
1624            BaseType::Object => "z.record(z.any())".to_string(),
1625            BaseType::Any => "z.any()".to_string(),
1626        }
1627    }
1628}
1629
1630/// Represents a TypeScript field in an interface
1631#[derive(Debug, Clone)]
1632struct TypeScriptField {
1633    name: String,
1634    ts_type: String,
1635    optional: bool,
1636    #[allow(dead_code)]
1637    description: Option<String>,
1638}
1639
1640#[derive(Debug, Clone)]
1641struct SchemaOutput {
1642    definitions: String,
1643    names: Vec<String>,
1644}
1645
1646/// Convert serde_json::Value to TypeScript type string
1647fn value_to_typescript_type(value: &serde_json::Value) -> String {
1648    match value {
1649        serde_json::Value::Number(_) => "number".to_string(),
1650        serde_json::Value::String(_) => "string".to_string(),
1651        serde_json::Value::Bool(_) => "boolean".to_string(),
1652        serde_json::Value::Array(_) => "any[]".to_string(),
1653        serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1654        serde_json::Value::Null => "null".to_string(),
1655    }
1656}
1657
1658fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
1659    let mut names = HashSet::new();
1660    let registry = crate::resolvers::builtin_resolver_registry();
1661    for resolver in registry.definitions() {
1662        let output_type = resolver.output_type();
1663        for section in &spec.sections {
1664            for field in &section.fields {
1665                if field.inner_type.as_deref() == Some(output_type) {
1666                    names.insert(output_type.to_string());
1667                }
1668            }
1669        }
1670    }
1671    names
1672}
1673
1674fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
1675    let mut names = HashSet::new();
1676    if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
1677        for type_def in types_array {
1678            if let (Some(type_name), Some(type_obj)) = (
1679                type_def.get("name").and_then(|v| v.as_str()),
1680                type_def.get("type").and_then(|v| v.as_object()),
1681            ) {
1682                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1683                    names.insert(type_name.to_string());
1684                }
1685            }
1686        }
1687    }
1688    names
1689}
1690
1691/// Extract enum type names that were actually emitted in the generated interfaces.
1692/// Looks for patterns like `export const DirectionKindSchema = z.enum([...])`
1693fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
1694    let mut names = HashSet::new();
1695
1696    // Get all enum type names from the IDL
1697    let idl_enum_names: HashSet<String> = idl
1698        .and_then(|idl| serde_json::to_value(idl).ok())
1699        .map(|v| extract_idl_enum_type_names(&v))
1700        .unwrap_or_default();
1701
1702    // Look for emitted enum schemas in the interfaces
1703    // Pattern: export const DirectionKindSchema = z.enum([...]) or z.string() for empty variants
1704    for line in interfaces.lines() {
1705        if let Some(start) = line.find("export const ") {
1706            let end = line
1707                .find("Schema = z.enum")
1708                .or_else(|| line.find("Schema = z.string()"));
1709            if let Some(end) = end {
1710                let schema_name = line[start + 13..end].trim();
1711                // Check if this schema name corresponds to an IDL enum type
1712                if idl_enum_names.contains(schema_name) {
1713                    names.insert(schema_name.to_string());
1714                }
1715            }
1716        }
1717    }
1718
1719    names
1720}
1721
1722/// Convert snake_case to PascalCase
1723fn to_pascal_case(s: &str) -> String {
1724    s.split(['_', '-', '.'])
1725        .map(|word| {
1726            let mut chars = word.chars();
1727            match chars.next() {
1728                None => String::new(),
1729                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1730            }
1731        })
1732        .collect()
1733}
1734
1735/// Normalize a name for case-insensitive comparison across naming conventions.
1736/// Removes underscores and converts to lowercase: "claim_sol", "claimSol", "ClaimSol" all become "claimsol"
1737fn normalize_for_comparison(s: &str) -> String {
1738    s.chars()
1739        .filter(|c| *c != '_')
1740        .flat_map(|c| c.to_lowercase())
1741        .collect()
1742}
1743
1744fn is_root_section(name: &str) -> bool {
1745    name.eq_ignore_ascii_case("root")
1746}
1747
1748fn is_builtin_resolver_type(type_name: &str) -> bool {
1749    crate::resolvers::is_resolver_output_type(type_name)
1750}
1751
1752/// Convert PascalCase/camelCase to kebab-case
1753fn to_kebab_case(s: &str) -> String {
1754    let mut result = String::new();
1755
1756    for ch in s.chars() {
1757        if ch.is_uppercase() && !result.is_empty() {
1758            result.push('-');
1759        }
1760        result.push(ch.to_lowercase().next().unwrap());
1761    }
1762
1763    result
1764}
1765
1766/// CLI-friendly function to generate TypeScript from a spec function
1767/// This will be used by the CLI tool to generate TypeScript from discovered specs
1768pub fn generate_typescript_from_spec_fn<F, S>(
1769    spec_fn: F,
1770    entity_name: String,
1771    config: Option<TypeScriptConfig>,
1772) -> Result<TypeScriptOutput, String>
1773where
1774    F: Fn() -> TypedStreamSpec<S>,
1775{
1776    let spec = spec_fn();
1777    let compiler =
1778        TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1779
1780    Ok(compiler.compile())
1781}
1782
1783/// Write TypeScript output to a file
1784pub fn write_typescript_to_file(
1785    output: &TypeScriptOutput,
1786    path: &std::path::Path,
1787) -> Result<(), std::io::Error> {
1788    std::fs::write(path, output.full_file())
1789}
1790
1791/// Generate TypeScript from a SerializableStreamSpec (for CLI use)
1792/// This allows the CLI to compile TypeScript without needing the typed spec
1793pub fn compile_serializable_spec(
1794    spec: SerializableStreamSpec,
1795    entity_name: String,
1796    config: Option<TypeScriptConfig>,
1797) -> Result<TypeScriptOutput, String> {
1798    compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
1799}
1800
1801fn compile_serializable_spec_with_emitted(
1802    spec: SerializableStreamSpec,
1803    entity_name: String,
1804    config: Option<TypeScriptConfig>,
1805    already_emitted_types: HashSet<String>,
1806) -> Result<TypeScriptOutput, String> {
1807    let idl = spec
1808        .idl
1809        .as_ref()
1810        .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1811
1812    let handlers = serde_json::to_value(&spec.handlers).ok();
1813    let views = spec.views.clone();
1814
1815    let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1816
1817    let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1818        .with_idl(idl)
1819        .with_handlers_json(handlers)
1820        .with_views(views)
1821        .with_config(config.unwrap_or_default())
1822        .with_already_emitted_types(already_emitted_types);
1823
1824    Ok(compiler.compile())
1825}
1826
1827#[derive(Debug, Clone)]
1828pub struct TypeScriptStackConfig {
1829    pub package_name: String,
1830    pub generate_helpers: bool,
1831    pub export_const_name: String,
1832    pub url: Option<String>,
1833}
1834
1835impl Default for TypeScriptStackConfig {
1836    fn default() -> Self {
1837        Self {
1838            package_name: "hyperstack-react".to_string(),
1839            generate_helpers: true,
1840            export_const_name: "STACK".to_string(),
1841            url: None,
1842        }
1843    }
1844}
1845
1846#[derive(Debug, Clone)]
1847pub struct TypeScriptStackOutput {
1848    pub interfaces: String,
1849    pub stack_definition: String,
1850    pub imports: String,
1851}
1852
1853impl TypeScriptStackOutput {
1854    pub fn full_file(&self) -> String {
1855        let mut parts = Vec::new();
1856        if !self.imports.is_empty() {
1857            parts.push(self.imports.as_str());
1858        }
1859        if !self.interfaces.is_empty() {
1860            parts.push(self.interfaces.as_str());
1861        }
1862        if !self.stack_definition.is_empty() {
1863            parts.push(self.stack_definition.as_str());
1864        }
1865        parts.join("\n\n")
1866    }
1867}
1868
1869/// Compile a full SerializableStackSpec (multi-entity) into a single TypeScript file.
1870///
1871/// Generates:
1872/// - Interfaces for ALL entities (OreRound, OreTreasury, OreMiner, etc.)
1873/// - A single unified stack definition with nested views per entity
1874/// - View helpers (stateView, listView)
1875pub fn compile_stack_spec(
1876    stack_spec: SerializableStackSpec,
1877    config: Option<TypeScriptStackConfig>,
1878) -> Result<TypeScriptStackOutput, String> {
1879    let config = config.unwrap_or_default();
1880    let stack_name = &stack_spec.stack_name;
1881    let stack_kebab = to_kebab_case(stack_name);
1882
1883    // 1. Compile each entity's interfaces using existing per-entity compiler
1884    let mut all_interfaces = Vec::new();
1885    let mut entity_names = Vec::new();
1886    let mut schema_names: Vec<String> = Vec::new();
1887    let mut emitted_types: HashSet<String> = HashSet::new();
1888
1889    for entity_spec in &stack_spec.entities {
1890        let mut spec = entity_spec.clone();
1891        // Inject stack-level IDL if entity doesn't have its own
1892        if spec.idl.is_none() {
1893            spec.idl = stack_spec.idls.first().cloned();
1894        }
1895        let entity_name = spec.state_name.clone();
1896        entity_names.push(entity_name.clone());
1897
1898        let per_entity_config = TypeScriptConfig {
1899            package_name: config.package_name.clone(),
1900            generate_helpers: false,
1901            interface_prefix: String::new(),
1902            export_const_name: config.export_const_name.clone(),
1903            url: config.url.clone(),
1904        };
1905
1906        // Collect builtin type names before spec is consumed
1907        let builtin_type_names = extract_builtin_resolver_type_names(&spec);
1908        // Clone IDL before spec is moved so we can check which enums were emitted
1909        let idl_for_check = spec.idl.clone();
1910
1911        let output = compile_serializable_spec_with_emitted(
1912            spec,
1913            entity_name,
1914            Some(per_entity_config),
1915            emitted_types.clone(),
1916        )?;
1917
1918        // Track shared types for cross-entity dedup
1919        // Only track enum types that were actually emitted (found in output.interfaces)
1920        let emitted_enum_names =
1921            extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
1922        emitted_types.extend(emitted_enum_names);
1923        emitted_types.extend(builtin_type_names);
1924
1925        // Only take the interfaces part (not the stack_definition — we generate our own)
1926        if !output.interfaces.is_empty() {
1927            all_interfaces.push(output.interfaces);
1928        }
1929
1930        schema_names.extend(output.schema_names);
1931    }
1932
1933    let interfaces = all_interfaces.join("\n\n");
1934
1935    // 2. Generate unified stack definition with all entity views
1936    let stack_definition = generate_stack_definition_multi(
1937        stack_name,
1938        &stack_kebab,
1939        &stack_spec.entities,
1940        &entity_names,
1941        &stack_spec.pdas,
1942        &stack_spec.program_ids,
1943        &schema_names,
1944        &config,
1945    );
1946
1947    let imports = if stack_spec.pdas.values().any(|p| !p.is_empty()) {
1948        "import { z } from 'zod';\nimport { pda, literal, account, arg, bytes } from 'hyperstack-typescript';".to_string()
1949    } else {
1950        "import { z } from 'zod';".to_string()
1951    };
1952
1953    Ok(TypeScriptStackOutput {
1954        imports,
1955        interfaces,
1956        stack_definition,
1957    })
1958}
1959
1960/// Write stack-level TypeScript output to a file
1961pub fn write_stack_typescript_to_file(
1962    output: &TypeScriptStackOutput,
1963    path: &std::path::Path,
1964) -> Result<(), std::io::Error> {
1965    std::fs::write(path, output.full_file())
1966}
1967
1968/// Generate a unified stack definition for multiple entities.
1969///
1970/// Produces something like:
1971/// ```typescript
1972/// export const ORE_STACK = {
1973///   name: 'ore',
1974///   url: 'wss://ore.stack.usehyperstack.com',
1975///   views: {
1976///     OreRound: {
1977///       state: stateView<OreRound>('OreRound/state'),
1978///       list: listView<OreRound>('OreRound/list'),
1979///       latest: listView<OreRound>('OreRound/latest'),
1980///     },
1981///     OreTreasury: {
1982///       state: stateView<OreTreasury>('OreTreasury/state'),
1983///     },
1984///     OreMiner: {
1985///       state: stateView<OreMiner>('OreMiner/state'),
1986///       list: listView<OreMiner>('OreMiner/list'),
1987///     },
1988///   },
1989/// } as const;
1990/// ```
1991#[allow(clippy::too_many_arguments)]
1992fn generate_stack_definition_multi(
1993    stack_name: &str,
1994    stack_kebab: &str,
1995    entities: &[SerializableStreamSpec],
1996    entity_names: &[String],
1997    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
1998    program_ids: &[String],
1999    schema_names: &[String],
2000    config: &TypeScriptStackConfig,
2001) -> String {
2002    let export_name = format!(
2003        "{}_{}",
2004        to_screaming_snake_case(stack_name),
2005        config.export_const_name
2006    );
2007
2008    let view_helpers = generate_view_helpers_static();
2009
2010    let url_line = match &config.url {
2011        Some(url) => format!("  url: '{}',", url),
2012        None => "  // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
2013    };
2014
2015    // Generate views block for each entity
2016    let mut entity_view_blocks = Vec::new();
2017    for (i, entity_spec) in entities.iter().enumerate() {
2018        let entity_name = &entity_names[i];
2019        let entity_pascal = to_pascal_case(entity_name);
2020
2021        let mut view_entries = Vec::new();
2022
2023        view_entries.push(format!(
2024            "      state: stateView<{entity}>('{entity_name}/state'),",
2025            entity = entity_pascal,
2026            entity_name = entity_name
2027        ));
2028
2029        view_entries.push(format!(
2030            "      list: listView<{entity}>('{entity_name}/list'),",
2031            entity = entity_pascal,
2032            entity_name = entity_name
2033        ));
2034
2035        for view in &entity_spec.views {
2036            if !view.id.ends_with("/state")
2037                && !view.id.ends_with("/list")
2038                && view.id.starts_with(entity_name)
2039            {
2040                let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
2041                view_entries.push(format!(
2042                    "      {}: listView<{entity}>('{}'),",
2043                    view_name,
2044                    view.id,
2045                    entity = entity_pascal
2046                ));
2047            }
2048        }
2049
2050        entity_view_blocks.push(format!(
2051            "    {}: {{\n{}\n    }},",
2052            entity_name,
2053            view_entries.join("\n")
2054        ));
2055    }
2056
2057    let views_body = entity_view_blocks.join("\n");
2058
2059    let pdas_block = generate_pdas_block(pdas, program_ids);
2060
2061    let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
2062    for name in schema_names {
2063        unique_schemas.insert(name.clone());
2064    }
2065    let schemas_block = if unique_schemas.is_empty() {
2066        String::new()
2067    } else {
2068        let schema_entries: Vec<String> = unique_schemas
2069            .iter()
2070            .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
2071            .collect();
2072        format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
2073    };
2074
2075    let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
2076
2077    format!(
2078        r#"{view_helpers}
2079
2080// ============================================================================
2081// Stack Definition
2082// ============================================================================
2083
2084/** Stack definition for {stack_name} with {entity_count} entities */
2085export const {export_name} = {{
2086  name: '{stack_kebab}',
2087{url_line}
2088  views: {{
2089{views_body}
2090  }},{schemas_section}{pdas_section}
2091}} as const;
2092
2093/** Type alias for the stack */
2094export type {stack_name}Stack = typeof {export_name};
2095
2096/** Entity types in this stack */
2097export type {stack_name}Entity = {entity_union};
2098
2099/** Default export for convenience */
2100export default {export_name};"#,
2101        view_helpers = view_helpers,
2102        stack_name = stack_name,
2103        entity_count = entities.len(),
2104        export_name = export_name,
2105        stack_kebab = stack_kebab,
2106        url_line = url_line,
2107        views_body = views_body,
2108        schemas_section = schemas_block,
2109        pdas_section = pdas_block,
2110        entity_union = entity_types.join(" | "),
2111    )
2112}
2113
2114fn generate_pdas_block(
2115    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
2116    program_ids: &[String],
2117) -> String {
2118    if pdas.is_empty() {
2119        return String::new();
2120    }
2121
2122    let mut program_blocks = Vec::new();
2123
2124    for (program_name, program_pdas) in pdas {
2125        if program_pdas.is_empty() {
2126            continue;
2127        }
2128
2129        let program_id = program_ids.first().cloned().unwrap_or_default();
2130
2131        let mut pda_entries = Vec::new();
2132        for (pda_name, pda_def) in program_pdas {
2133            let seeds_str = pda_def
2134                .seeds
2135                .iter()
2136                .map(|seed| match seed {
2137                    PdaSeedDef::Literal { value } => format!("literal('{}')", value),
2138                    PdaSeedDef::AccountRef { account_name } => {
2139                        format!("account('{}')", account_name)
2140                    }
2141                    PdaSeedDef::ArgRef { arg_name, arg_type } => {
2142                        if let Some(t) = arg_type {
2143                            format!("arg('{}', '{}')", arg_name, t)
2144                        } else {
2145                            format!("arg('{}')", arg_name)
2146                        }
2147                    }
2148                    PdaSeedDef::Bytes { value } => {
2149                        let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
2150                        format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
2151                    }
2152                })
2153                .collect::<Vec<_>>()
2154                .join(", ");
2155
2156            let pid = pda_def.program_id.as_ref().unwrap_or(&program_id);
2157            pda_entries.push(format!(
2158                "      {}: pda('{}', {}),",
2159                pda_name, pid, seeds_str
2160            ));
2161        }
2162
2163        program_blocks.push(format!(
2164            "    {}: {{\n{}\n    }},",
2165            program_name,
2166            pda_entries.join("\n")
2167        ));
2168    }
2169
2170    if program_blocks.is_empty() {
2171        return String::new();
2172    }
2173
2174    format!("\n  pdas: {{\n{}\n  }},", program_blocks.join("\n"))
2175}
2176
2177fn generate_view_helpers_static() -> String {
2178    r#"// ============================================================================
2179// View Definition Types (framework-agnostic)
2180// ============================================================================
2181
2182/** View definition with embedded entity type */
2183export interface ViewDef<T, TMode extends 'state' | 'list'> {
2184  readonly mode: TMode;
2185  readonly view: string;
2186  /** Phantom field for type inference - not present at runtime */
2187  readonly _entity?: T;
2188}
2189
2190/** Helper to create typed state view definitions (keyed lookups) */
2191function stateView<T>(view: string): ViewDef<T, 'state'> {
2192  return { mode: 'state', view } as const;
2193}
2194
2195/** Helper to create typed list view definitions (collections) */
2196function listView<T>(view: string): ViewDef<T, 'list'> {
2197  return { mode: 'list', view } as const;
2198}"#
2199    .to_string()
2200}
2201
2202/// Convert PascalCase to SCREAMING_SNAKE_CASE (e.g., "OreStream" -> "ORE_STREAM")
2203fn to_screaming_snake_case(s: &str) -> String {
2204    let mut result = String::new();
2205    for (i, ch) in s.chars().enumerate() {
2206        if ch.is_uppercase() && i > 0 {
2207            result.push('_');
2208        }
2209        result.push(ch.to_uppercase().next().unwrap());
2210    }
2211    result
2212}
2213
2214#[cfg(test)]
2215mod tests {
2216    use super::*;
2217
2218    #[test]
2219    fn test_case_conversions() {
2220        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
2221        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
2222    }
2223
2224    #[test]
2225    fn test_normalize_for_comparison() {
2226        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
2227        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
2228        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
2229        assert_eq!(
2230            normalize_for_comparison("admin_set_creator"),
2231            "adminsetcreator"
2232        );
2233        assert_eq!(
2234            normalize_for_comparison("AdminSetCreator"),
2235            "adminsetcreator"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_value_to_typescript_type() {
2241        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
2242        assert_eq!(
2243            value_to_typescript_type(&serde_json::json!("hello")),
2244            "string"
2245        );
2246        assert_eq!(
2247            value_to_typescript_type(&serde_json::json!(true)),
2248            "boolean"
2249        );
2250        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
2251    }
2252
2253    #[test]
2254    fn test_derived_view_codegen() {
2255        let spec = SerializableStreamSpec {
2256            state_name: "OreRound".to_string(),
2257            program_id: None,
2258            idl: None,
2259            identity: IdentitySpec {
2260                primary_keys: vec!["id".to_string()],
2261                lookup_indexes: vec![],
2262            },
2263            handlers: vec![],
2264            sections: vec![],
2265            field_mappings: BTreeMap::new(),
2266            resolver_hooks: vec![],
2267            resolver_specs: vec![],
2268            instruction_hooks: vec![],
2269            computed_fields: vec![],
2270            computed_field_specs: vec![],
2271            content_hash: None,
2272            views: vec![
2273                ViewDef {
2274                    id: "OreRound/latest".to_string(),
2275                    source: ViewSource::Entity {
2276                        name: "OreRound".to_string(),
2277                    },
2278                    pipeline: vec![ViewTransform::Last],
2279                    output: ViewOutput::Single,
2280                },
2281                ViewDef {
2282                    id: "OreRound/top10".to_string(),
2283                    source: ViewSource::Entity {
2284                        name: "OreRound".to_string(),
2285                    },
2286                    pipeline: vec![ViewTransform::Take { count: 10 }],
2287                    output: ViewOutput::Collection,
2288                },
2289            ],
2290        };
2291
2292        let output =
2293            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
2294
2295        let stack_def = &output.stack_definition;
2296
2297        assert!(
2298            stack_def.contains("listView<OreRound>('OreRound/latest')"),
2299            "Expected 'latest' derived view using listView, got:\n{}",
2300            stack_def
2301        );
2302        assert!(
2303            stack_def.contains("listView<OreRound>('OreRound/top10')"),
2304            "Expected 'top10' derived view using listView, got:\n{}",
2305            stack_def
2306        );
2307        assert!(
2308            stack_def.contains("latest:"),
2309            "Expected 'latest' key, got:\n{}",
2310            stack_def
2311        );
2312        assert!(
2313            stack_def.contains("top10:"),
2314            "Expected 'top10' key, got:\n{}",
2315            stack_def
2316        );
2317        assert!(
2318            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
2319            "Expected listView helper function, got:\n{}",
2320            stack_def
2321        );
2322    }
2323}