Skip to main content

hyperstack_interpreter/
typescript.rs

1use crate::ast::*;
2use std::collections::{BTreeMap, 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}
11
12impl TypeScriptOutput {
13    pub fn full_file(&self) -> String {
14        format!(
15            "{}\n\n{}\n\n{}",
16            self.imports, self.interfaces, self.stack_definition
17        )
18    }
19}
20
21/// Configuration for TypeScript generation
22#[derive(Debug, Clone)]
23pub struct TypeScriptConfig {
24    pub package_name: String,
25    pub generate_helpers: bool,
26    pub interface_prefix: String,
27    pub export_const_name: String,
28    /// WebSocket URL for the stack. If None, generates a placeholder comment.
29    pub url: Option<String>,
30}
31
32impl Default for TypeScriptConfig {
33    fn default() -> Self {
34        Self {
35            package_name: "hyperstack-react".to_string(),
36            generate_helpers: true,
37            interface_prefix: "".to_string(),
38            export_const_name: "STACK".to_string(),
39            url: None,
40        }
41    }
42}
43
44/// Trait for generating TypeScript code from AST components
45pub trait TypeScriptGenerator {
46    fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
47}
48
49/// Trait for generating TypeScript interfaces
50pub trait TypeScriptInterfaceGenerator {
51    fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
52}
53
54/// Trait for generating TypeScript type mappings
55pub trait TypeScriptTypeMapper {
56    fn to_typescript_type(&self) -> String;
57}
58
59/// Main TypeScript compiler for stream specs
60pub struct TypeScriptCompiler<S> {
61    spec: TypedStreamSpec<S>,
62    entity_name: String,
63    config: TypeScriptConfig,
64    idl: Option<serde_json::Value>, // IDL for enum type generation
65    handlers_json: Option<serde_json::Value>, // Raw handlers for event interface generation
66    views: Vec<ViewDef>,            // View definitions for derived views
67}
68
69impl<S> TypeScriptCompiler<S> {
70    pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
71        Self {
72            spec,
73            entity_name,
74            config: TypeScriptConfig::default(),
75            idl: None,
76            handlers_json: None,
77            views: Vec::new(),
78        }
79    }
80
81    pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
82        self.config = config;
83        self
84    }
85
86    pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
87        self.idl = idl;
88        self
89    }
90
91    pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
92        self.handlers_json = handlers;
93        self
94    }
95
96    pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
97        self.views = views;
98        self
99    }
100
101    pub fn compile(&self) -> TypeScriptOutput {
102        let imports = self.generate_imports();
103        let interfaces = self.generate_interfaces();
104        let stack_definition = self.generate_stack_definition();
105
106        TypeScriptOutput {
107            imports,
108            interfaces,
109            stack_definition,
110        }
111    }
112
113    fn generate_imports(&self) -> String {
114        // No imports needed - generated file is self-contained
115        String::new()
116    }
117
118    fn generate_view_helpers(&self) -> String {
119        r#"// ============================================================================
120// View Definition Types (framework-agnostic)
121// ============================================================================
122
123/** View definition with embedded entity type */
124export interface ViewDef<T, TMode extends 'state' | 'list'> {
125  readonly mode: TMode;
126  readonly view: string;
127  /** Phantom field for type inference - not present at runtime */
128  readonly _entity?: T;
129}
130
131/** Helper to create typed state view definitions (keyed lookups) */
132function stateView<T>(view: string): ViewDef<T, 'state'> {
133  return { mode: 'state', view } as const;
134}
135
136/** Helper to create typed list view definitions (collections) */
137function listView<T>(view: string): ViewDef<T, 'list'> {
138  return { mode: 'list', view } as const;
139}"#
140        .to_string()
141    }
142
143    fn generate_interfaces(&self) -> String {
144        let mut interfaces = Vec::new();
145        let mut processed_types = HashSet::new();
146        let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
147
148        // Collect all interface sections from all handlers
149        for handler in &self.spec.handlers {
150            let interface_sections = self.extract_interface_sections_from_handler(handler);
151
152            for (section_name, mut fields) in interface_sections {
153                all_sections
154                    .entry(section_name)
155                    .or_default()
156                    .append(&mut fields);
157            }
158        }
159
160        // Add unmapped fields from spec.sections ONCE (not per handler)
161        // These are fields without #[map] or #[event] attributes
162        self.add_unmapped_fields(&mut all_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        // Generate nested interfaces for resolved types (instructions, accounts, etc.)
180        let nested_interfaces = self.generate_nested_interfaces();
181        interfaces.extend(nested_interfaces);
182
183        // Generate EventWrapper interface if there are any event types
184        if self.has_event_types() {
185            interfaces.push(self.generate_event_wrapper_interface());
186        }
187
188        interfaces.join("\n\n")
189    }
190
191    fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
192        let mut seen = HashSet::new();
193        let mut unique_fields = Vec::new();
194
195        // Sort fields by name for consistent output
196        fields.sort_by(|a, b| a.name.cmp(&b.name));
197
198        for field in fields {
199            if seen.insert(field.name.clone()) {
200                unique_fields.push(field);
201            }
202        }
203
204        unique_fields
205    }
206
207    fn extract_interface_sections_from_handler(
208        &self,
209        handler: &TypedHandlerSpec<S>,
210    ) -> BTreeMap<String, Vec<TypeScriptField>> {
211        let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
212
213        for mapping in &handler.mappings {
214            if !mapping.emit {
215                continue;
216            }
217            let parts: Vec<&str> = mapping.target_path.split('.').collect();
218
219            if parts.len() > 1 {
220                let section_name = parts[0];
221                let field_name = parts[1];
222
223                let ts_field = TypeScriptField {
224                    name: field_name.to_string(),
225                    ts_type: self.mapping_to_typescript_type(mapping),
226                    optional: self.is_field_optional(mapping),
227                    description: None,
228                };
229
230                sections
231                    .entry(section_name.to_string())
232                    .or_default()
233                    .push(ts_field);
234            } else {
235                let ts_field = TypeScriptField {
236                    name: mapping.target_path.clone(),
237                    ts_type: self.mapping_to_typescript_type(mapping),
238                    optional: self.is_field_optional(mapping),
239                    description: None,
240                };
241
242                sections
243                    .entry("Root".to_string())
244                    .or_default()
245                    .push(ts_field);
246            }
247        }
248
249        sections
250    }
251
252    fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
253        // NEW: Enhanced approach using AST type information if available
254        if !self.spec.sections.is_empty() {
255            // Use type information from the enhanced AST
256            for section in &self.spec.sections {
257                let section_fields = sections.entry(section.name.clone()).or_default();
258
259                for field_info in &section.fields {
260                    if !field_info.emit {
261                        continue;
262                    }
263                    // Check if field is already mapped
264                    let already_exists = section_fields
265                        .iter()
266                        .any(|f| f.name == field_info.field_name);
267
268                    if !already_exists {
269                        section_fields.push(TypeScriptField {
270                            name: field_info.field_name.clone(),
271                            ts_type: self.field_type_info_to_typescript(field_info),
272                            optional: field_info.is_optional,
273                            description: None,
274                        });
275                    }
276                }
277            }
278        } else {
279            // FALLBACK: Use field mappings from spec if sections aren't available yet
280            for (field_path, field_type_info) in &self.spec.field_mappings {
281                if !field_type_info.emit {
282                    continue;
283                }
284                let parts: Vec<&str> = field_path.split('.').collect();
285                if parts.len() > 1 {
286                    let section_name = parts[0];
287                    let field_name = parts[1];
288
289                    let section_fields = sections.entry(section_name.to_string()).or_default();
290
291                    let already_exists = section_fields.iter().any(|f| f.name == field_name);
292
293                    if !already_exists {
294                        section_fields.push(TypeScriptField {
295                            name: field_name.to_string(),
296                            ts_type: self.base_type_to_typescript(
297                                &field_type_info.base_type,
298                                field_type_info.is_array,
299                            ),
300                            optional: field_type_info.is_optional,
301                            description: None,
302                        });
303                    }
304                }
305            }
306        }
307    }
308
309    fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
310        // Generate more descriptive interface names
311        let interface_name = if name == "Root" {
312            format!(
313                "{}{}",
314                self.config.interface_prefix,
315                to_pascal_case(&self.entity_name)
316            )
317        } else {
318            // Create compound names like GameEvents, GameStatus, etc.
319            // Extract the base name (e.g., "Game" from "TestGame" or "SettlementGame")
320            let base_name = if self.entity_name.contains("Game") {
321                "Game"
322            } else {
323                &self.entity_name
324            };
325            format!(
326                "{}{}{}",
327                self.config.interface_prefix,
328                base_name,
329                to_pascal_case(name)
330            )
331        };
332
333        // All fields are optional (?) since we receive patches - field may not yet exist
334        // For spec-optional fields, we use `T | null` to distinguish "explicitly null" from "not received"
335        let field_definitions: Vec<String> = fields
336            .iter()
337            .map(|field| {
338                let ts_type = if field.optional {
339                    // Spec-optional: can be explicitly null
340                    format!("{} | null", field.ts_type)
341                } else {
342                    field.ts_type.clone()
343                };
344                format!("  {}?: {};", field.name, ts_type)
345            })
346            .collect();
347
348        format!(
349            "export interface {} {{\n{}\n}}",
350            interface_name,
351            field_definitions.join("\n")
352        )
353    }
354
355    fn generate_main_entity_interface(&self) -> String {
356        let entity_name = to_pascal_case(&self.entity_name);
357
358        // Extract all top-level sections from the handlers
359        let mut sections = BTreeMap::new();
360
361        for handler in &self.spec.handlers {
362            for mapping in &handler.mappings {
363                if !mapping.emit {
364                    continue;
365                }
366                let parts: Vec<&str> = mapping.target_path.split('.').collect();
367                if parts.len() > 1 {
368                    sections.insert(parts[0], true);
369                }
370            }
371        }
372
373        if !self.spec.sections.is_empty() {
374            for section in &self.spec.sections {
375                if section.fields.iter().any(|field| field.emit) {
376                    sections.insert(&section.name, true);
377                }
378            }
379        } else {
380            for mapping in &self.spec.handlers {
381                for field_mapping in &mapping.mappings {
382                    if !field_mapping.emit {
383                        continue;
384                    }
385                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
386                    if parts.len() > 1 {
387                        sections.insert(parts[0], true);
388                    }
389                }
390            }
391        }
392
393        let mut fields = Vec::new();
394
395        // Add non-root sections as nested interface references
396        // All fields are optional since we receive patches
397        for section in sections.keys() {
398            if !is_root_section(section) {
399                let base_name = if self.entity_name.contains("Game") {
400                    "Game"
401                } else {
402                    &self.entity_name
403                };
404                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
405                // Keep section field names as-is (snake_case from AST)
406                fields.push(format!("  {}?: {};", section, section_interface_name));
407            }
408        }
409
410        // Flatten root section fields directly into main interface
411        // All fields are optional (?) since we receive patches
412        for section in &self.spec.sections {
413            if is_root_section(&section.name) {
414                for field in &section.fields {
415                    if !field.emit {
416                        continue;
417                    }
418                    let base_ts_type = self.field_type_info_to_typescript(field);
419                    let ts_type = if field.is_optional {
420                        format!("{} | null", base_ts_type)
421                    } else {
422                        base_ts_type
423                    };
424                    fields.push(format!("  {}?: {};", field.field_name, ts_type));
425                }
426            }
427        }
428
429        if fields.is_empty() {
430            fields.push("  // Generated interface - extend as needed".to_string());
431        }
432
433        format!(
434            "export interface {} {{\n{}\n}}",
435            entity_name,
436            fields.join("\n")
437        )
438    }
439
440    fn generate_stack_definition(&self) -> String {
441        let stack_name = to_kebab_case(&self.entity_name);
442        let entity_pascal = to_pascal_case(&self.entity_name);
443        let export_name = format!(
444            "{}_{}",
445            self.entity_name.to_uppercase(),
446            self.config.export_const_name
447        );
448
449        let view_helpers = self.generate_view_helpers();
450        let derived_views = self.generate_derived_view_entries();
451
452        // Generate URL line - either actual URL or placeholder comment
453        let url_line = match &self.config.url {
454            Some(url) => format!("  url: '{}',", url),
455            None => "  // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
456        };
457
458        format!(
459            r#"{}
460
461// ============================================================================
462// Stack Definition
463// ============================================================================
464
465/** Stack definition for {} */
466export const {} = {{
467  name: '{}',
468{}
469  views: {{
470    {}: {{
471      state: stateView<{}>('{}/state'),
472      list: listView<{}>('{}/list'),{}
473    }},
474  }},
475}} as const;
476
477/** Type alias for the stack */
478export type {}Stack = typeof {};
479
480/** Default export for convenience */
481export default {};"#,
482            view_helpers,
483            entity_pascal,
484            export_name,
485            stack_name,
486            url_line,
487            self.entity_name,
488            entity_pascal,
489            self.entity_name,
490            entity_pascal,
491            self.entity_name,
492            derived_views,
493            entity_pascal,
494            export_name,
495            export_name
496        )
497    }
498
499    fn generate_derived_view_entries(&self) -> String {
500        let derived_views: Vec<&ViewDef> = self
501            .views
502            .iter()
503            .filter(|v| {
504                !v.id.ends_with("/state")
505                    && !v.id.ends_with("/list")
506                    && v.id.starts_with(&self.entity_name)
507            })
508            .collect();
509
510        if derived_views.is_empty() {
511            return String::new();
512        }
513
514        let entity_pascal = to_pascal_case(&self.entity_name);
515        let mut entries = Vec::new();
516
517        for view in derived_views {
518            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
519
520            entries.push(format!(
521                "\n      {}: listView<{}>('{}'),",
522                view_name, entity_pascal, view.id
523            ));
524        }
525
526        entries.join("")
527    }
528
529    fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
530        // First, try to resolve from AST field mappings
531        if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
532            let ts_type = self.field_type_info_to_typescript(field_info);
533
534            // If it's an Append strategy, wrap in array
535            if matches!(mapping.population, PopulationStrategy::Append) {
536                return if ts_type.ends_with("[]") {
537                    ts_type
538                } else {
539                    format!("{}[]", ts_type)
540                };
541            }
542
543            return ts_type;
544        }
545
546        // Fallback to legacy inference
547        match &mapping.population {
548            PopulationStrategy::Append => {
549                // For arrays, try to infer the element type
550                match &mapping.source {
551                    MappingSource::AsEvent { .. } => "any[]".to_string(),
552                    _ => "any[]".to_string(),
553                }
554            }
555            _ => {
556                // Infer type from source and field name
557                let base_type = match &mapping.source {
558                    MappingSource::FromSource { .. } => {
559                        self.infer_type_from_field_name(&mapping.target_path)
560                    }
561                    MappingSource::Constant(value) => value_to_typescript_type(value),
562                    MappingSource::AsEvent { .. } => "any".to_string(),
563                    _ => "any".to_string(),
564                };
565
566                // Apply transformations to type
567                if let Some(transform) = &mapping.transform {
568                    match transform {
569                        Transformation::HexEncode | Transformation::HexDecode => {
570                            "string".to_string()
571                        }
572                        Transformation::Base58Encode | Transformation::Base58Decode => {
573                            "string".to_string()
574                        }
575                        Transformation::ToString => "string".to_string(),
576                        Transformation::ToNumber => "number".to_string(),
577                    }
578                } else {
579                    base_type
580                }
581            }
582        }
583    }
584
585    /// Convert FieldTypeInfo from AST to TypeScript type string
586    fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
587        // If we have resolved type information (complex types from IDL), use it
588        if let Some(resolved) = &field_info.resolved_type {
589            let interface_name = self.resolved_type_to_interface_name(resolved);
590
591            // Wrap in EventWrapper if it's an event type
592            let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
593            {
594                format!("EventWrapper<{}>", interface_name)
595            } else {
596                interface_name
597            };
598
599            // Handle optional and array
600            let with_array = if field_info.is_array {
601                format!("{}[]", base_type)
602            } else {
603                base_type
604            };
605
606            return with_array;
607        }
608
609        // Check if this is an event field (has BaseType::Any or BaseType::Array with Value inner type)
610        // We can detect event fields by looking for them in handlers with AsEvent mappings
611        if field_info.base_type == BaseType::Any
612            || (field_info.base_type == BaseType::Array
613                && field_info.inner_type.as_deref() == Some("Value"))
614        {
615            if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
616                return if field_info.is_array {
617                    format!("{}[]", event_type)
618                } else if field_info.is_optional {
619                    format!("{} | null", event_type)
620                } else {
621                    event_type
622                };
623            }
624        }
625
626        // Use base type mapping
627        self.base_type_to_typescript(&field_info.base_type, field_info.is_array)
628    }
629
630    /// Find the generated event interface name for a given field
631    fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
632        // Use the raw JSON handlers if available
633        let handlers = self.handlers_json.as_ref()?.as_array()?;
634
635        // Look through handlers to find event mappings for this field
636        for handler in handlers {
637            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
638                for mapping in mappings {
639                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
640                        // Check if this mapping targets our field (e.g., "events.created")
641                        let target_parts: Vec<&str> = target_path.split('.').collect();
642                        if let Some(target_field) = target_parts.last() {
643                            if *target_field == field_name {
644                                // Check if this is an event mapping
645                                if let Some(source) = mapping.get("source") {
646                                    if self.extract_event_data(source).is_some() {
647                                        // Generate the interface name (e.g., "created" -> "CreatedEvent")
648                                        return Some(format!(
649                                            "{}Event",
650                                            to_pascal_case(field_name)
651                                        ));
652                                    }
653                                }
654                            }
655                        }
656                    }
657                }
658            }
659        }
660        None
661    }
662
663    /// Generate TypeScript interface name from resolved type
664    fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
665        to_pascal_case(&resolved.type_name)
666    }
667
668    /// Generate nested interfaces for all resolved types in the AST
669    fn generate_nested_interfaces(&self) -> Vec<String> {
670        let mut interfaces = Vec::new();
671        let mut generated_types = HashSet::new();
672
673        // Collect all resolved types from all sections
674        for section in &self.spec.sections {
675            for field_info in &section.fields {
676                if let Some(resolved) = &field_info.resolved_type {
677                    let type_name = resolved.type_name.clone();
678
679                    // Only generate each type once
680                    if generated_types.insert(type_name) {
681                        let interface = self.generate_interface_for_resolved_type(resolved);
682                        interfaces.push(interface);
683                    }
684                }
685            }
686        }
687
688        // Generate event interfaces from instruction handlers
689        interfaces.extend(self.generate_event_interfaces(&mut generated_types));
690
691        // Also generate all enum types from the IDL (even if not directly referenced)
692        if let Some(idl_value) = &self.idl {
693            if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
694                for type_def in types_array {
695                    if let (Some(type_name), Some(type_obj)) = (
696                        type_def.get("name").and_then(|v| v.as_str()),
697                        type_def.get("type").and_then(|v| v.as_object()),
698                    ) {
699                        if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
700                            // Only generate if not already generated
701                            if generated_types.insert(type_name.to_string()) {
702                                if let Some(variants) =
703                                    type_obj.get("variants").and_then(|v| v.as_array())
704                                {
705                                    let variant_names: Vec<String> = variants
706                                        .iter()
707                                        .filter_map(|v| {
708                                            v.get("name")
709                                                .and_then(|n| n.as_str())
710                                                .map(|s| s.to_string())
711                                        })
712                                        .collect();
713
714                                    if !variant_names.is_empty() {
715                                        let interface_name = to_pascal_case(type_name);
716                                        let variant_strings: Vec<String> = variant_names
717                                            .iter()
718                                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
719                                            .collect();
720
721                                        let enum_type = format!(
722                                            "export type {} = {};",
723                                            interface_name,
724                                            variant_strings.join(" | ")
725                                        );
726                                        interfaces.push(enum_type);
727                                    }
728                                }
729                            }
730                        }
731                    }
732                }
733            }
734        }
735
736        interfaces
737    }
738
739    /// Generate TypeScript interfaces for event types from instruction handlers
740    fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
741        let mut interfaces = Vec::new();
742
743        // Use the raw JSON handlers if available
744        let handlers = match &self.handlers_json {
745            Some(h) => h.as_array(),
746            None => return interfaces,
747        };
748
749        let handlers_array = match handlers {
750            Some(arr) => arr,
751            None => return interfaces,
752        };
753
754        // Look through handlers to find instruction-based event mappings
755        for handler in handlers_array {
756            // Check if this handler has event mappings
757            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
758                for mapping in mappings {
759                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
760                        // Check if the target is an event field (contains ".events." or starts with "events.")
761                        if target_path.contains(".events.") || target_path.starts_with("events.") {
762                            // Check if the source is AsEvent
763                            if let Some(source) = mapping.get("source") {
764                                if let Some(event_data) = self.extract_event_data(source) {
765                                    // Extract instruction name from handler source
766                                    if let Some(handler_source) = handler.get("source") {
767                                        if let Some(instruction_name) =
768                                            self.extract_instruction_name(handler_source)
769                                        {
770                                            // Generate interface name from target path (e.g., "events.created" -> "CreatedEvent")
771                                            let event_field_name =
772                                                target_path.split('.').next_back().unwrap_or("");
773                                            let interface_name = format!(
774                                                "{}Event",
775                                                to_pascal_case(event_field_name)
776                                            );
777
778                                            // Only generate once
779                                            if generated_types.insert(interface_name.clone()) {
780                                                if let Some(interface) = self
781                                                    .generate_event_interface_from_idl(
782                                                        &interface_name,
783                                                        &instruction_name,
784                                                        &event_data,
785                                                    )
786                                                {
787                                                    interfaces.push(interface);
788                                                }
789                                            }
790                                        }
791                                    }
792                                }
793                            }
794                        }
795                    }
796                }
797            }
798        }
799
800        interfaces
801    }
802
803    /// Extract event field data from a mapping source
804    fn extract_event_data(
805        &self,
806        source: &serde_json::Value,
807    ) -> Option<Vec<(String, Option<String>)>> {
808        if let Some(as_event) = source.get("AsEvent") {
809            if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
810                let mut event_fields = Vec::new();
811                for field in fields {
812                    if let Some(from_source) = field.get("FromSource") {
813                        if let Some(path) = from_source
814                            .get("path")
815                            .and_then(|p| p.get("segments"))
816                            .and_then(|s| s.as_array())
817                        {
818                            // Get the last segment as the field name (e.g., ["data", "game_id"] -> "game_id")
819                            if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
820                                let transform = from_source
821                                    .get("transform")
822                                    .and_then(|t| t.as_str())
823                                    .map(|s| s.to_string());
824                                event_fields.push((field_name.to_string(), transform));
825                            }
826                        }
827                    }
828                }
829                return Some(event_fields);
830            }
831        }
832        None
833    }
834
835    /// Extract instruction name from handler source, returning the raw PascalCase name
836    fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
837        if let Some(source_obj) = source.get("Source") {
838            if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
839                let instruction_part =
840                    crate::event_type_helpers::strip_event_type_suffix(type_name);
841                return Some(instruction_part.to_string());
842            }
843        }
844        None
845    }
846
847    /// Find an instruction in the IDL by name, handling different naming conventions.
848    /// IDLs may use snake_case (pumpfun: "admin_set_creator") or camelCase (ore: "claimSol").
849    /// The input name comes from Rust types which are PascalCase ("AdminSetCreator", "ClaimSol").
850    fn find_instruction_in_idl<'a>(
851        &self,
852        instructions: &'a [serde_json::Value],
853        rust_name: &str,
854    ) -> Option<&'a serde_json::Value> {
855        let normalized_search = normalize_for_comparison(rust_name);
856
857        for instruction in instructions {
858            if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
859                if normalize_for_comparison(idl_name) == normalized_search {
860                    return Some(instruction);
861                }
862            }
863        }
864        None
865    }
866
867    /// Generate a TypeScript interface for an event from IDL instruction data
868    fn generate_event_interface_from_idl(
869        &self,
870        interface_name: &str,
871        rust_instruction_name: &str,
872        captured_fields: &[(String, Option<String>)],
873    ) -> Option<String> {
874        if captured_fields.is_empty() {
875            return Some(format!("export interface {} {{}}", interface_name));
876        }
877
878        let idl_value = self.idl.as_ref()?;
879        let instructions = idl_value.get("instructions")?.as_array()?;
880
881        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
882        let args = instruction.get("args")?.as_array()?;
883
884        let mut fields = Vec::new();
885        for (field_name, transform) in captured_fields {
886            for arg in args {
887                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
888                    if arg_name == field_name {
889                        if let Some(arg_type) = arg.get("type") {
890                            let ts_type =
891                                self.idl_type_to_typescript(arg_type, transform.as_deref());
892                            fields.push(format!("  {}: {};", field_name, ts_type));
893                        }
894                        break;
895                    }
896                }
897            }
898        }
899
900        if !fields.is_empty() {
901            return Some(format!(
902                "export interface {} {{\n{}\n}}",
903                interface_name,
904                fields.join("\n")
905            ));
906        }
907
908        None
909    }
910
911    /// Convert an IDL type (from JSON) to TypeScript, considering transforms
912    fn idl_type_to_typescript(
913        &self,
914        idl_type: &serde_json::Value,
915        transform: Option<&str>,
916    ) -> String {
917        #![allow(clippy::only_used_in_recursion)]
918        // If there's a HexEncode transform, the result is always a string
919        if transform == Some("HexEncode") {
920            return "string".to_string();
921        }
922
923        // Handle different IDL type formats
924        if let Some(type_str) = idl_type.as_str() {
925            return match type_str {
926                "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" => {
927                    "number".to_string()
928                }
929                "f32" | "f64" => "number".to_string(),
930                "bool" => "boolean".to_string(),
931                "string" => "string".to_string(),
932                "pubkey" | "publicKey" => "string".to_string(),
933                "bytes" => "string".to_string(),
934                _ => "any".to_string(),
935            };
936        }
937
938        // Handle complex types (option, vec, etc.)
939        if let Some(type_obj) = idl_type.as_object() {
940            if let Some(option_type) = type_obj.get("option") {
941                let inner = self.idl_type_to_typescript(option_type, None);
942                return format!("{} | null", inner);
943            }
944            if let Some(vec_type) = type_obj.get("vec") {
945                let inner = self.idl_type_to_typescript(vec_type, None);
946                return format!("{}[]", inner);
947            }
948        }
949
950        "any".to_string()
951    }
952
953    /// Generate a TypeScript interface from a resolved struct type
954    fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
955        let interface_name = to_pascal_case(&resolved.type_name);
956
957        // Handle enums as TypeScript union types
958        if resolved.is_enum {
959            let variants: Vec<String> = resolved
960                .enum_variants
961                .iter()
962                .map(|v| format!("\"{}\"", to_pascal_case(v)))
963                .collect();
964
965            return format!("export type {} = {};", interface_name, variants.join(" | "));
966        }
967
968        // Handle structs as interfaces
969        // All fields are optional since we receive patches
970        let fields: Vec<String> = resolved
971            .fields
972            .iter()
973            .map(|field| {
974                let base_ts_type = self.resolved_field_to_typescript(field);
975                let ts_type = if field.is_optional {
976                    format!("{} | null", base_ts_type)
977                } else {
978                    base_ts_type
979                };
980                format!("  {}?: {};", field.field_name, ts_type)
981            })
982            .collect();
983
984        format!(
985            "export interface {} {{\n{}\n}}",
986            interface_name,
987            fields.join("\n")
988        )
989    }
990
991    /// Convert a resolved field to TypeScript type
992    fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
993        let base_ts = self.base_type_to_typescript(&field.base_type, false);
994
995        if field.is_array {
996            format!("{}[]", base_ts)
997        } else {
998            base_ts
999        }
1000    }
1001
1002    /// Check if the spec has any event types
1003    fn has_event_types(&self) -> bool {
1004        for section in &self.spec.sections {
1005            for field_info in &section.fields {
1006                if let Some(resolved) = &field_info.resolved_type {
1007                    if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1008                        return true;
1009                    }
1010                }
1011            }
1012        }
1013        false
1014    }
1015
1016    /// Generate the EventWrapper interface
1017    fn generate_event_wrapper_interface(&self) -> String {
1018        r#"/**
1019 * Wrapper for event data that includes context metadata.
1020 * Events are automatically wrapped in this structure at runtime.
1021 */
1022export interface EventWrapper<T> {
1023  /** Unix timestamp when the event was processed */
1024  timestamp: number;
1025  /** The event-specific data */
1026  data: T;
1027  /** Optional blockchain slot number */
1028  slot?: number;
1029  /** Optional transaction signature */
1030  signature?: string;
1031}"#
1032        .to_string()
1033    }
1034
1035    fn infer_type_from_field_name(&self, field_name: &str) -> String {
1036        let lower_name = field_name.to_lowercase();
1037
1038        // Special case for event fields - these are typically Option<Value> and should be 'any'
1039        if lower_name.contains("events.") {
1040            // For fields in the events section, default to 'any' since they're typically Option<Value>
1041            return "any".to_string();
1042        }
1043
1044        // Common patterns for type inference
1045        if lower_name.contains("id")
1046            || lower_name.contains("count")
1047            || lower_name.contains("number")
1048            || lower_name.contains("timestamp")
1049            || lower_name.contains("time")
1050            || lower_name.contains("at")
1051            || lower_name.contains("volume")
1052            || lower_name.contains("amount")
1053            || lower_name.contains("ev")
1054            || lower_name.contains("fee")
1055            || lower_name.contains("payout")
1056            || lower_name.contains("distributed")
1057            || lower_name.contains("claimable")
1058            || lower_name.contains("total")
1059            || lower_name.contains("rate")
1060            || lower_name.contains("ratio")
1061            || lower_name.contains("current")
1062            || lower_name.contains("state")
1063        {
1064            "number".to_string()
1065        } else if lower_name.contains("status")
1066            || lower_name.contains("hash")
1067            || lower_name.contains("address")
1068            || lower_name.contains("key")
1069        {
1070            "string".to_string()
1071        } else {
1072            "any".to_string()
1073        }
1074    }
1075
1076    fn is_field_optional(&self, mapping: &TypedFieldMapping<S>) -> bool {
1077        // Most fields should be optional by default since we're dealing with Option<T> types
1078        match &mapping.source {
1079            // Constants are typically non-optional
1080            MappingSource::Constant(_) => false,
1081            // Events are typically optional (Option<Value>)
1082            MappingSource::AsEvent { .. } => true,
1083            // For source fields, default to optional since most Rust fields are Option<T>
1084            MappingSource::FromSource { .. } => true,
1085            // Other cases default to optional
1086            _ => true,
1087        }
1088    }
1089
1090    /// Convert language-agnostic base types to TypeScript types
1091    fn base_type_to_typescript(&self, base_type: &BaseType, is_array: bool) -> String {
1092        let base_ts_type = match base_type {
1093            BaseType::Integer => "number",
1094            BaseType::Float => "number",
1095            BaseType::String => "string",
1096            BaseType::Boolean => "boolean",
1097            BaseType::Timestamp => "number", // Unix timestamps as numbers
1098            BaseType::Binary => "string",    // Base64 encoded strings
1099            BaseType::Pubkey => "string",    // Solana public keys as Base58 strings
1100            BaseType::Array => "any[]",      // Default array type
1101            BaseType::Object => "Record<string, any>", // Generic object
1102            BaseType::Any => "any",
1103        };
1104
1105        if is_array && !matches!(base_type, BaseType::Array) {
1106            format!("{}[]", base_ts_type)
1107        } else {
1108            base_ts_type.to_string()
1109        }
1110    }
1111}
1112
1113/// Represents a TypeScript field in an interface
1114#[derive(Debug, Clone)]
1115struct TypeScriptField {
1116    name: String,
1117    ts_type: String,
1118    optional: bool,
1119    #[allow(dead_code)]
1120    description: Option<String>,
1121}
1122
1123/// Convert serde_json::Value to TypeScript type string
1124fn value_to_typescript_type(value: &serde_json::Value) -> String {
1125    match value {
1126        serde_json::Value::Number(_) => "number".to_string(),
1127        serde_json::Value::String(_) => "string".to_string(),
1128        serde_json::Value::Bool(_) => "boolean".to_string(),
1129        serde_json::Value::Array(_) => "any[]".to_string(),
1130        serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1131        serde_json::Value::Null => "null".to_string(),
1132    }
1133}
1134
1135/// Convert snake_case to PascalCase
1136fn to_pascal_case(s: &str) -> String {
1137    s.split(['_', '-', '.'])
1138        .map(|word| {
1139            let mut chars = word.chars();
1140            match chars.next() {
1141                None => String::new(),
1142                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1143            }
1144        })
1145        .collect()
1146}
1147
1148/// Normalize a name for case-insensitive comparison across naming conventions.
1149/// Removes underscores and converts to lowercase: "claim_sol", "claimSol", "ClaimSol" all become "claimsol"
1150fn normalize_for_comparison(s: &str) -> String {
1151    s.chars()
1152        .filter(|c| *c != '_')
1153        .flat_map(|c| c.to_lowercase())
1154        .collect()
1155}
1156
1157/// Check if a section name is the root section (case-insensitive)
1158fn is_root_section(name: &str) -> bool {
1159    name.eq_ignore_ascii_case("root")
1160}
1161
1162/// Convert PascalCase/camelCase to kebab-case
1163fn to_kebab_case(s: &str) -> String {
1164    let mut result = String::new();
1165
1166    for ch in s.chars() {
1167        if ch.is_uppercase() && !result.is_empty() {
1168            result.push('-');
1169        }
1170        result.push(ch.to_lowercase().next().unwrap());
1171    }
1172
1173    result
1174}
1175
1176/// CLI-friendly function to generate TypeScript from a spec function
1177/// This will be used by the CLI tool to generate TypeScript from discovered specs
1178pub fn generate_typescript_from_spec_fn<F, S>(
1179    spec_fn: F,
1180    entity_name: String,
1181    config: Option<TypeScriptConfig>,
1182) -> Result<TypeScriptOutput, String>
1183where
1184    F: Fn() -> TypedStreamSpec<S>,
1185{
1186    let spec = spec_fn();
1187    let compiler =
1188        TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
1189
1190    Ok(compiler.compile())
1191}
1192
1193/// Write TypeScript output to a file
1194pub fn write_typescript_to_file(
1195    output: &TypeScriptOutput,
1196    path: &std::path::Path,
1197) -> Result<(), std::io::Error> {
1198    std::fs::write(path, output.full_file())
1199}
1200
1201/// Generate TypeScript from a SerializableStreamSpec (for CLI use)
1202/// This allows the CLI to compile TypeScript without needing the typed spec
1203pub fn compile_serializable_spec(
1204    spec: SerializableStreamSpec,
1205    entity_name: String,
1206    config: Option<TypeScriptConfig>,
1207) -> Result<TypeScriptOutput, String> {
1208    let idl = spec
1209        .idl
1210        .as_ref()
1211        .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
1212
1213    let handlers = serde_json::to_value(&spec.handlers).ok();
1214    let views = spec.views.clone();
1215
1216    let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
1217
1218    let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
1219        .with_idl(idl)
1220        .with_handlers_json(handlers)
1221        .with_views(views)
1222        .with_config(config.unwrap_or_default());
1223
1224    Ok(compiler.compile())
1225}
1226
1227#[derive(Debug, Clone)]
1228pub struct TypeScriptStackConfig {
1229    pub package_name: String,
1230    pub generate_helpers: bool,
1231    pub export_const_name: String,
1232    pub url: Option<String>,
1233}
1234
1235impl Default for TypeScriptStackConfig {
1236    fn default() -> Self {
1237        Self {
1238            package_name: "hyperstack-react".to_string(),
1239            generate_helpers: true,
1240            export_const_name: "STACK".to_string(),
1241            url: None,
1242        }
1243    }
1244}
1245
1246#[derive(Debug, Clone)]
1247pub struct TypeScriptStackOutput {
1248    pub interfaces: String,
1249    pub stack_definition: String,
1250    pub imports: String,
1251}
1252
1253impl TypeScriptStackOutput {
1254    pub fn full_file(&self) -> String {
1255        let mut parts = Vec::new();
1256        if !self.imports.is_empty() {
1257            parts.push(self.imports.as_str());
1258        }
1259        if !self.interfaces.is_empty() {
1260            parts.push(self.interfaces.as_str());
1261        }
1262        if !self.stack_definition.is_empty() {
1263            parts.push(self.stack_definition.as_str());
1264        }
1265        parts.join("\n\n")
1266    }
1267}
1268
1269/// Compile a full SerializableStackSpec (multi-entity) into a single TypeScript file.
1270///
1271/// Generates:
1272/// - Interfaces for ALL entities (OreRound, OreTreasury, OreMiner, etc.)
1273/// - A single unified stack definition with nested views per entity
1274/// - View helpers (stateView, listView)
1275pub fn compile_stack_spec(
1276    stack_spec: SerializableStackSpec,
1277    config: Option<TypeScriptStackConfig>,
1278) -> Result<TypeScriptStackOutput, String> {
1279    let config = config.unwrap_or_default();
1280    let stack_name = &stack_spec.stack_name;
1281    let stack_kebab = to_kebab_case(stack_name);
1282
1283    // 1. Compile each entity's interfaces using existing per-entity compiler
1284    let mut all_interfaces = Vec::new();
1285    let mut entity_names = Vec::new();
1286
1287    for entity_spec in &stack_spec.entities {
1288        let mut spec = entity_spec.clone();
1289        // Inject stack-level IDL if entity doesn't have its own
1290        if spec.idl.is_none() {
1291            spec.idl = stack_spec.idls.first().cloned();
1292        }
1293        let entity_name = spec.state_name.clone();
1294        entity_names.push(entity_name.clone());
1295
1296        let per_entity_config = TypeScriptConfig {
1297            package_name: config.package_name.clone(),
1298            generate_helpers: false,
1299            interface_prefix: String::new(),
1300            export_const_name: config.export_const_name.clone(),
1301            url: config.url.clone(),
1302        };
1303
1304        let output = compile_serializable_spec(spec, entity_name, Some(per_entity_config))?;
1305
1306        // Only take the interfaces part (not the stack_definition — we generate our own)
1307        if !output.interfaces.is_empty() {
1308            all_interfaces.push(output.interfaces);
1309        }
1310    }
1311
1312    let interfaces = all_interfaces.join("\n\n");
1313
1314    // 2. Generate unified stack definition with all entity views
1315    let stack_definition = generate_stack_definition_multi(
1316        stack_name,
1317        &stack_kebab,
1318        &stack_spec.entities,
1319        &entity_names,
1320        &config,
1321    );
1322
1323    Ok(TypeScriptStackOutput {
1324        imports: String::new(),
1325        interfaces,
1326        stack_definition,
1327    })
1328}
1329
1330/// Write stack-level TypeScript output to a file
1331pub fn write_stack_typescript_to_file(
1332    output: &TypeScriptStackOutput,
1333    path: &std::path::Path,
1334) -> Result<(), std::io::Error> {
1335    std::fs::write(path, output.full_file())
1336}
1337
1338/// Generate a unified stack definition for multiple entities.
1339///
1340/// Produces something like:
1341/// ```typescript
1342/// export const ORE_STACK = {
1343///   name: 'ore',
1344///   url: 'wss://ore.stack.usehyperstack.com',
1345///   views: {
1346///     OreRound: {
1347///       state: stateView<OreRound>('OreRound/state'),
1348///       list: listView<OreRound>('OreRound/list'),
1349///       latest: listView<OreRound>('OreRound/latest'),
1350///     },
1351///     OreTreasury: {
1352///       state: stateView<OreTreasury>('OreTreasury/state'),
1353///     },
1354///     OreMiner: {
1355///       state: stateView<OreMiner>('OreMiner/state'),
1356///       list: listView<OreMiner>('OreMiner/list'),
1357///     },
1358///   },
1359/// } as const;
1360/// ```
1361fn generate_stack_definition_multi(
1362    stack_name: &str,
1363    stack_kebab: &str,
1364    entities: &[SerializableStreamSpec],
1365    entity_names: &[String],
1366    config: &TypeScriptStackConfig,
1367) -> String {
1368    let export_name = format!(
1369        "{}_{}",
1370        to_screaming_snake_case(stack_name),
1371        config.export_const_name
1372    );
1373
1374    let view_helpers = generate_view_helpers_static();
1375
1376    let url_line = match &config.url {
1377        Some(url) => format!("  url: '{}',", url),
1378        None => "  // url: 'wss://your-stack-url.stack.usehyperstack.com', // TODO: Set after first deployment".to_string(),
1379    };
1380
1381    // Generate views block for each entity
1382    let mut entity_view_blocks = Vec::new();
1383    for (i, entity_spec) in entities.iter().enumerate() {
1384        let entity_name = &entity_names[i];
1385        let entity_pascal = to_pascal_case(entity_name);
1386
1387        let mut view_entries = Vec::new();
1388
1389        // Always include state view
1390        view_entries.push(format!(
1391            "      state: stateView<{entity}>('{entity_name}/state'),",
1392            entity = entity_pascal,
1393            entity_name = entity_name
1394        ));
1395
1396        // Always include list view (built-in view, like state)
1397        view_entries.push(format!(
1398            "      list: listView<{entity}>('{entity_name}/list'),",
1399            entity = entity_pascal,
1400            entity_name = entity_name
1401        ));
1402
1403        // Include derived views
1404        for view in &entity_spec.views {
1405            if !view.id.ends_with("/state")
1406                && !view.id.ends_with("/list")
1407                && view.id.starts_with(entity_name)
1408            {
1409                let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1410                view_entries.push(format!(
1411                    "      {}: listView<{entity}>('{}'),",
1412                    view_name,
1413                    view.id,
1414                    entity = entity_pascal
1415                ));
1416            }
1417        }
1418
1419        entity_view_blocks.push(format!(
1420            "    {}: {{\n{}\n    }},",
1421            entity_name,
1422            view_entries.join("\n")
1423        ));
1424    }
1425
1426    let views_body = entity_view_blocks.join("\n");
1427
1428    // Generate entity type union for convenience
1429    let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
1430
1431    format!(
1432        r#"{view_helpers}
1433
1434// ============================================================================
1435// Stack Definition
1436// ============================================================================
1437
1438/** Stack definition for {stack_name} with {entity_count} entities */
1439export const {export_name} = {{
1440  name: '{stack_kebab}',
1441{url_line}
1442  views: {{
1443{views_body}
1444  }},
1445}} as const;
1446
1447/** Type alias for the stack */
1448export type {stack_name}Stack = typeof {export_name};
1449
1450/** Entity types in this stack */
1451export type {stack_name}Entity = {entity_union};
1452
1453/** Default export for convenience */
1454export default {export_name};"#,
1455        view_helpers = view_helpers,
1456        stack_name = stack_name,
1457        entity_count = entities.len(),
1458        export_name = export_name,
1459        stack_kebab = stack_kebab,
1460        url_line = url_line,
1461        views_body = views_body,
1462        entity_union = entity_types.join(" | "),
1463    )
1464}
1465
1466fn generate_view_helpers_static() -> String {
1467    r#"// ============================================================================
1468// View Definition Types (framework-agnostic)
1469// ============================================================================
1470
1471/** View definition with embedded entity type */
1472export interface ViewDef<T, TMode extends 'state' | 'list'> {
1473  readonly mode: TMode;
1474  readonly view: string;
1475  /** Phantom field for type inference - not present at runtime */
1476  readonly _entity?: T;
1477}
1478
1479/** Helper to create typed state view definitions (keyed lookups) */
1480function stateView<T>(view: string): ViewDef<T, 'state'> {
1481  return { mode: 'state', view } as const;
1482}
1483
1484/** Helper to create typed list view definitions (collections) */
1485function listView<T>(view: string): ViewDef<T, 'list'> {
1486  return { mode: 'list', view } as const;
1487}"#
1488    .to_string()
1489}
1490
1491/// Convert PascalCase to SCREAMING_SNAKE_CASE (e.g., "OreStream" -> "ORE_STREAM")
1492fn to_screaming_snake_case(s: &str) -> String {
1493    let mut result = String::new();
1494    for (i, ch) in s.chars().enumerate() {
1495        if ch.is_uppercase() && i > 0 {
1496            result.push('_');
1497        }
1498        result.push(ch.to_uppercase().next().unwrap());
1499    }
1500    result
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506
1507    #[test]
1508    fn test_case_conversions() {
1509        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
1510        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
1511    }
1512
1513    #[test]
1514    fn test_normalize_for_comparison() {
1515        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
1516        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
1517        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
1518        assert_eq!(
1519            normalize_for_comparison("admin_set_creator"),
1520            "adminsetcreator"
1521        );
1522        assert_eq!(
1523            normalize_for_comparison("AdminSetCreator"),
1524            "adminsetcreator"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_value_to_typescript_type() {
1530        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
1531        assert_eq!(
1532            value_to_typescript_type(&serde_json::json!("hello")),
1533            "string"
1534        );
1535        assert_eq!(
1536            value_to_typescript_type(&serde_json::json!(true)),
1537            "boolean"
1538        );
1539        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
1540    }
1541
1542    #[test]
1543    fn test_derived_view_codegen() {
1544        let spec = SerializableStreamSpec {
1545            state_name: "OreRound".to_string(),
1546            program_id: None,
1547            idl: None,
1548            identity: IdentitySpec {
1549                primary_keys: vec!["id".to_string()],
1550                lookup_indexes: vec![],
1551            },
1552            handlers: vec![],
1553            sections: vec![],
1554            field_mappings: BTreeMap::new(),
1555            resolver_hooks: vec![],
1556            instruction_hooks: vec![],
1557            computed_fields: vec![],
1558            computed_field_specs: vec![],
1559            content_hash: None,
1560            views: vec![
1561                ViewDef {
1562                    id: "OreRound/latest".to_string(),
1563                    source: ViewSource::Entity {
1564                        name: "OreRound".to_string(),
1565                    },
1566                    pipeline: vec![ViewTransform::Last],
1567                    output: ViewOutput::Single,
1568                },
1569                ViewDef {
1570                    id: "OreRound/top10".to_string(),
1571                    source: ViewSource::Entity {
1572                        name: "OreRound".to_string(),
1573                    },
1574                    pipeline: vec![ViewTransform::Take { count: 10 }],
1575                    output: ViewOutput::Collection,
1576                },
1577            ],
1578        };
1579
1580        let output =
1581            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
1582
1583        let stack_def = &output.stack_definition;
1584
1585        assert!(
1586            stack_def.contains("listView<OreRound>('OreRound/latest')"),
1587            "Expected 'latest' derived view using listView, got:\n{}",
1588            stack_def
1589        );
1590        assert!(
1591            stack_def.contains("listView<OreRound>('OreRound/top10')"),
1592            "Expected 'top10' derived view using listView, got:\n{}",
1593            stack_def
1594        );
1595        assert!(
1596            stack_def.contains("latest:"),
1597            "Expected 'latest' key, got:\n{}",
1598            stack_def
1599        );
1600        assert!(
1601            stack_def.contains("top10:"),
1602            "Expected 'top10' key, got:\n{}",
1603            stack_def
1604        );
1605        assert!(
1606            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
1607            "Expected listView helper function, got:\n{}",
1608            stack_def
1609        );
1610    }
1611}