Skip to main content

arete_interpreter/
typescript.rs

1use crate::ast::*;
2use arete_idl::utils::to_snake_case as idl_to_snake_case;
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5/// Output structure for TypeScript generation
6#[derive(Debug, Clone)]
7pub struct TypeScriptOutput {
8    pub interfaces: String,
9    pub stack_definition: String,
10    pub imports: String,
11    pub schema_names: Vec<String>,
12}
13
14impl TypeScriptOutput {
15    pub fn full_file(&self) -> String {
16        format!(
17            "{}\n\n{}\n\n{}",
18            self.imports, self.interfaces, self.stack_definition
19        )
20    }
21}
22
23/// Configuration for TypeScript generation
24#[derive(Debug, Clone)]
25pub struct TypeScriptConfig {
26    pub package_name: String,
27    pub generate_helpers: bool,
28    pub interface_prefix: String,
29    pub export_const_name: String,
30    /// WebSocket URL for the stack. If None, generates a placeholder comment.
31    pub url: Option<String>,
32}
33
34impl Default for TypeScriptConfig {
35    fn default() -> Self {
36        Self {
37            package_name: "@usearete/react".to_string(),
38            generate_helpers: true,
39            interface_prefix: "".to_string(),
40            export_const_name: "STACK".to_string(),
41            url: None,
42        }
43    }
44}
45
46/// Trait for generating TypeScript code from AST components
47pub trait TypeScriptGenerator {
48    fn generate_typescript(&self, config: &TypeScriptConfig) -> String;
49}
50
51/// Trait for generating TypeScript interfaces
52pub trait TypeScriptInterfaceGenerator {
53    fn generate_interface(&self, name: &str, config: &TypeScriptConfig) -> String;
54}
55
56/// Trait for generating TypeScript type mappings
57pub trait TypeScriptTypeMapper {
58    fn to_typescript_type(&self) -> String;
59}
60
61/// Main TypeScript compiler for stream specs
62pub struct TypeScriptCompiler<S> {
63    spec: TypedStreamSpec<S>,
64    entity_name: String,
65    config: TypeScriptConfig,
66    idl: Option<serde_json::Value>, // IDL for enum type generation
67    handlers_json: Option<serde_json::Value>, // Raw handlers for event interface generation
68    views: Vec<ViewDef>,            // View definitions for derived views
69    already_emitted_types: HashSet<String>,
70}
71
72impl<S> TypeScriptCompiler<S> {
73    pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
74        Self {
75            spec,
76            entity_name,
77            config: TypeScriptConfig::default(),
78            idl: None,
79            handlers_json: None,
80            views: Vec::new(),
81            already_emitted_types: HashSet::new(),
82        }
83    }
84
85    pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
86        self.config = config;
87        self
88    }
89
90    pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
91        self.idl = idl;
92        self
93    }
94
95    pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
96        self.handlers_json = handlers;
97        self
98    }
99
100    pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
101        self.views = views;
102        self
103    }
104
105    pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
106        self.already_emitted_types = types;
107        self
108    }
109
110    pub fn compile(&self) -> TypeScriptOutput {
111        let imports = self.generate_imports();
112        let interfaces = self.generate_interfaces();
113        let schema_output = self.generate_schemas();
114        let combined_interfaces = if schema_output.definitions.is_empty() {
115            interfaces
116        } else if interfaces.is_empty() {
117            schema_output.definitions.clone()
118        } else {
119            format!("{}\n\n{}", interfaces, schema_output.definitions)
120        };
121        let stack_definition = self.generate_stack_definition();
122
123        TypeScriptOutput {
124            imports,
125            interfaces: combined_interfaces,
126            stack_definition,
127            schema_names: schema_output.names,
128        }
129    }
130
131    fn generate_imports(&self) -> String {
132        "import { z } from 'zod';".to_string()
133    }
134
135    fn generate_view_helpers(&self) -> String {
136        r#"// ============================================================================
137// View Definition Types (framework-agnostic)
138// ============================================================================
139
140/** View definition with embedded entity type */
141export interface ViewDef<T, TMode extends 'state' | 'list'> {
142  readonly mode: TMode;
143  readonly view: string;
144  /** Phantom field for type inference - not present at runtime */
145  readonly _entity?: T;
146}
147
148/** Helper to create typed state view definitions (keyed lookups) */
149function stateView<T>(view: string): ViewDef<T, 'state'> {
150  return { mode: 'state', view } as const;
151}
152
153/** Helper to create typed list view definitions (collections) */
154function listView<T>(view: string): ViewDef<T, 'list'> {
155  return { mode: 'list', view } as const;
156}"#
157        .to_string()
158    }
159
160    fn generate_interfaces(&self) -> String {
161        let mut interfaces = Vec::new();
162        let mut processed_types = HashSet::new();
163        let all_sections = self.collect_interface_sections();
164
165        // Deduplicate fields within each section and generate interfaces
166        // Skip root section - its fields will be flattened into main entity interface
167        for (section_name, fields) in all_sections {
168            if !is_root_section(&section_name) && processed_types.insert(section_name.clone()) {
169                let deduplicated_fields = self.deduplicate_fields(fields);
170                let interface =
171                    self.generate_interface_from_fields(&section_name, &deduplicated_fields);
172                interfaces.push(interface);
173            }
174        }
175
176        // Generate main entity interface
177        let main_interface = self.generate_main_entity_interface();
178        interfaces.push(main_interface);
179
180        let nested_interfaces = self.generate_nested_interfaces();
181        interfaces.extend(nested_interfaces);
182
183        let builtin_interfaces = self.generate_builtin_resolver_interfaces();
184        interfaces.extend(builtin_interfaces);
185
186        if self.has_event_types() {
187            interfaces.push(self.generate_event_wrapper_interface());
188        }
189
190        interfaces.join("\n\n")
191    }
192
193    fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
194        let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
195
196        // Collect all interface sections from all handlers
197        for handler in &self.spec.handlers {
198            let interface_sections = self.extract_interface_sections_from_handler(handler);
199
200            for (section_name, mut fields) in interface_sections {
201                all_sections
202                    .entry(section_name)
203                    .or_default()
204                    .append(&mut fields);
205            }
206        }
207
208        // Add unmapped fields from spec.sections ONCE (not per handler)
209        // These are fields without #[map] or #[event] attributes
210        self.add_unmapped_fields(&mut all_sections);
211
212        all_sections
213    }
214
215    fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
216        let mut seen = HashSet::new();
217        let mut unique_fields = Vec::new();
218
219        // Sort fields by name for consistent output
220        fields.sort_by(|a, b| a.name.cmp(&b.name));
221
222        for field in fields {
223            if seen.insert(field.name.clone()) {
224                unique_fields.push(field);
225            }
226        }
227
228        unique_fields
229    }
230
231    fn extract_interface_sections_from_handler(
232        &self,
233        handler: &TypedHandlerSpec<S>,
234    ) -> BTreeMap<String, Vec<TypeScriptField>> {
235        let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
236
237        for mapping in &handler.mappings {
238            if !mapping.emit {
239                continue;
240            }
241            let parts: Vec<&str> = mapping.target_path.split('.').collect();
242
243            if parts.len() > 1 {
244                let section_name = parts[0];
245                let field_name = parts[1];
246
247                let ts_field = TypeScriptField::patch(
248                    field_name.to_string(),
249                    self.mapping_to_typescript_type(mapping),
250                    self.is_field_nullable(mapping),
251                );
252
253                sections
254                    .entry(section_name.to_string())
255                    .or_default()
256                    .push(ts_field);
257            } else {
258                let ts_field = TypeScriptField::patch(
259                    mapping.target_path.clone(),
260                    self.mapping_to_typescript_type(mapping),
261                    self.is_field_nullable(mapping),
262                );
263
264                sections
265                    .entry("Root".to_string())
266                    .or_default()
267                    .push(ts_field);
268            }
269        }
270
271        sections
272    }
273
274    fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
275        // NEW: Enhanced approach using AST type information if available
276        if !self.spec.sections.is_empty() {
277            // Use type information from the enhanced AST
278            for section in &self.spec.sections {
279                let section_fields = sections.entry(section.name.clone()).or_default();
280
281                for field_info in &section.fields {
282                    if !field_info.emit {
283                        continue;
284                    }
285                    // Check if field is already mapped
286                    let already_exists = section_fields
287                        .iter()
288                        .any(|f| f.name == field_info.field_name);
289
290                    if !already_exists {
291                        // For computed fields, check field_mappings for resolver type info
292                        let field_path = format!("{}.{}", section.name, field_info.field_name);
293                        let effective_field_info =
294                            if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
295                                // Use mapping's inner_type if it's a resolver output type
296                                if mapping
297                                    .inner_type
298                                    .as_ref()
299                                    .is_some_and(|t| is_builtin_resolver_type(t))
300                                {
301                                    mapping
302                                } else {
303                                    field_info
304                                }
305                            } else {
306                                field_info
307                            };
308                        let (raw_name, canonical_name) = localize_section_field_names(
309                            section.name.as_str(),
310                            effective_field_info,
311                        );
312
313                        section_fields.push(TypeScriptField::from_names(
314                            raw_name,
315                            canonical_name,
316                            self.field_type_info_to_typescript(effective_field_info),
317                            effective_field_info.is_optional,
318                            FieldPresence::Patch,
319                        ));
320                    }
321                }
322            }
323        } else {
324            // FALLBACK: Use field mappings from spec if sections aren't available yet
325            for (field_path, field_type_info) in &self.spec.field_mappings {
326                if !field_type_info.emit {
327                    continue;
328                }
329                let parts: Vec<&str> = field_path.split('.').collect();
330                if parts.len() > 1 {
331                    let section_name = parts[0];
332                    let field_name = parts[1];
333
334                    let section_fields = sections.entry(section_name.to_string()).or_default();
335
336                    let already_exists = section_fields.iter().any(|f| f.name == field_name);
337
338                    if !already_exists {
339                        section_fields.push(TypeScriptField::from_names(
340                            field_name.to_string(),
341                            field_type_info.canonical_field_name(),
342                            self.base_type_to_typescript(
343                                &field_type_info.base_type,
344                                field_type_info.effective_integer_kind(),
345                                field_type_info.is_array,
346                            ),
347                            field_type_info.is_optional,
348                            FieldPresence::Patch,
349                        ));
350                    }
351                }
352            }
353        }
354    }
355
356    fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
357        let interface_name = self.section_interface_name(name);
358        render_interface_from_ts_fields(&interface_name, fields, true)
359    }
360
361    fn section_interface_name(&self, name: &str) -> String {
362        if name == "Root" {
363            format!(
364                "{}{}",
365                self.config.interface_prefix,
366                to_pascal_case(&self.entity_name)
367            )
368        } else {
369            // Create compound names like GameEvents, GameStatus, etc.
370            // Extract the base name (e.g., "Game" from "TestGame" or "SettlementGame")
371            let base_name = if self.entity_name.contains("Game") {
372                "Game"
373            } else {
374                &self.entity_name
375            };
376            format!(
377                "{}{}{}",
378                self.config.interface_prefix,
379                base_name,
380                to_pascal_case(name)
381            )
382        }
383    }
384
385    fn generate_main_entity_interface(&self) -> String {
386        let entity_name = to_pascal_case(&self.entity_name);
387
388        let main_fields = self.collect_main_entity_fields();
389        if main_fields.is_empty() {
390            return format!(
391                "export interface {} {{\n  // Generated interface - extend as needed\n}}",
392                entity_name
393            );
394        }
395
396        render_interface_from_ts_fields(&entity_name, &main_fields, true)
397    }
398
399    fn generate_schemas(&self) -> SchemaOutput {
400        let patch_schema_types = self.patch_schema_type_names();
401        let mut definitions = Vec::new();
402        let mut names = Vec::new();
403        let mut seen = HashSet::new();
404
405        let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
406            if seen.insert(schema_name.clone()) {
407                if export_name {
408                    names.push(schema_name);
409                }
410                definitions.push(definition);
411            }
412        };
413
414        for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
415            push_schema(schema_name, definition, true);
416        }
417
418        if self.has_event_types() {
419            push_schema(
420                "EventWrapperSchema".to_string(),
421                self.generate_event_wrapper_schema(),
422                true,
423            );
424        }
425
426        for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
427            push_schema(schema_name, definition, true);
428        }
429
430        for (schema_name, definition) in
431            self.generate_resolved_type_patch_schemas(&patch_schema_types)
432        {
433            push_schema(schema_name, definition, false);
434        }
435
436        for (schema_name, definition) in self.generate_event_schemas() {
437            push_schema(schema_name, definition, true);
438        }
439
440        for (schema_name, definition) in self.generate_idl_enum_schemas() {
441            push_schema(schema_name, definition, true);
442        }
443
444        let all_sections = self.collect_interface_sections();
445
446        for (section_name, fields) in &all_sections {
447            if is_root_section(section_name) {
448                continue;
449            }
450            let deduplicated_fields = self.deduplicate_fields(fields.clone());
451            let interface_name = self.section_interface_name(section_name);
452            let schema_definition = self.generate_schema_for_fields(
453                &interface_name,
454                &deduplicated_fields,
455                true,
456                SchemaMode::Canonical,
457                &patch_schema_types,
458            );
459            push_schema(format!("{}Schema", interface_name), schema_definition, true);
460
461            let patch_schema_definition = self.generate_schema_for_fields(
462                &interface_name,
463                &deduplicated_fields,
464                false,
465                SchemaMode::Patch,
466                &patch_schema_types,
467            );
468            push_schema(
469                format!("{}PatchSchema", interface_name),
470                patch_schema_definition,
471                false,
472            );
473        }
474
475        let entity_name = to_pascal_case(&self.entity_name);
476        let main_fields = self.collect_main_entity_fields();
477        let entity_schema = self.generate_schema_for_fields(
478            &entity_name,
479            &main_fields,
480            true,
481            SchemaMode::Canonical,
482            &patch_schema_types,
483        );
484        push_schema(format!("{}Schema", entity_name), entity_schema, true);
485
486        let patch_schema = self.generate_schema_for_fields(
487            &entity_name,
488            &main_fields,
489            false,
490            SchemaMode::Patch,
491            &patch_schema_types,
492        );
493        push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
494
495        let completed_schema =
496            self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
497        push_schema(
498            format!("{}CompletedSchema", entity_name),
499            completed_schema,
500            true,
501        );
502
503        SchemaOutput {
504            definitions: definitions.join("\n\n"),
505            names,
506        }
507    }
508
509    fn generate_event_wrapper_schema(&self) -> String {
510        r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
511  timestamp: z.number(),
512  data,
513  slot: z.number().optional(),
514  signature: z.string().optional(),
515});"#
516            .to_string()
517    }
518
519    fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
520        let mut schemas = Vec::new();
521        let registry = crate::resolvers::builtin_resolver_registry();
522
523        for resolver in registry.definitions() {
524            let output_type = resolver.output_type();
525            let should_emit = self.uses_builtin_type(output_type)
526                && !self.already_emitted_types.contains(output_type);
527
528            // Also check if any types from the resolver's typescript_schema are used
529            let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
530                // Extract type names from export statements (simple string parsing)
531                ts_schema.definition.lines().any(|line| {
532                    let line = line.trim();
533                    // Match "export const TypeNameSchema"
534                    if let Some(rest) = line.strip_prefix("export const ") {
535                        let parts: Vec<&str> = rest.split_whitespace().collect();
536                        if parts.len() >= 2 && parts[1] == "=" {
537                            // Extract the base type name from "TypeNameSchema"
538                            let schema_name = parts[0];
539                            if let Some(type_name) = schema_name.strip_suffix("Schema") {
540                                return self.uses_builtin_type(type_name)
541                                    && !self.already_emitted_types.contains(type_name);
542                            }
543                        }
544                    }
545                    false
546                })
547            } else {
548                false
549            };
550
551            if (should_emit || extra_types_used)
552                && !self.already_emitted_types.contains(output_type)
553            {
554                if let Some(schema) = resolver.typescript_schema() {
555                    schemas.push((schema.name.to_string(), schema.definition.to_string()));
556                }
557            }
558        }
559
560        schemas
561    }
562
563    fn uses_builtin_type(&self, type_name: &str) -> bool {
564        // Check section fields
565        for section in &self.spec.sections {
566            for field in &section.fields {
567                if field.inner_type.as_deref() == Some(type_name) {
568                    return true;
569                }
570            }
571        }
572        // Check field_mappings for computed fields (they may have resolver types not in sections)
573        for field_info in self.spec.field_mappings.values() {
574            if field_info.inner_type.as_deref() == Some(type_name) {
575                return true;
576            }
577        }
578        false
579    }
580
581    fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
582        let mut interfaces = Vec::new();
583        let registry = crate::resolvers::builtin_resolver_registry();
584
585        for resolver in registry.definitions() {
586            let output_type = resolver.output_type();
587            let should_emit = self.uses_builtin_type(output_type)
588                && !self.already_emitted_types.contains(output_type);
589
590            // Also check if any types from the resolver's typescript_interface are used
591            let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
592                // Extract type names from export statements (simple string parsing)
593                ts_interface.lines().any(|line| {
594                    let line = line.trim();
595                    // Match "export type TypeName" or "export interface TypeName"
596                    if let Some(rest) = line.strip_prefix("export type ") {
597                        if let Some(type_name) = rest.split_whitespace().next() {
598                            return self.uses_builtin_type(type_name)
599                                && !self.already_emitted_types.contains(type_name);
600                        }
601                    } else if let Some(rest) = line.strip_prefix("export interface ") {
602                        if let Some(type_name) = rest.split_whitespace().next() {
603                            return self.uses_builtin_type(type_name)
604                                && !self.already_emitted_types.contains(type_name);
605                        }
606                    }
607                    false
608                })
609            } else {
610                false
611            };
612
613            if should_emit || extra_types_used {
614                if let Some(interface) = resolver.typescript_interface() {
615                    interfaces.push(interface.to_string());
616                }
617            }
618        }
619
620        interfaces
621    }
622
623    fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
624        let mut sections = BTreeMap::new();
625
626        for handler in &self.spec.handlers {
627            for mapping in &handler.mappings {
628                if !mapping.emit {
629                    continue;
630                }
631                let parts: Vec<&str> = mapping.target_path.split('.').collect();
632                if parts.len() > 1 {
633                    sections.insert(parts[0], true);
634                }
635            }
636        }
637
638        if !self.spec.sections.is_empty() {
639            for section in &self.spec.sections {
640                if section.fields.iter().any(|field| field.emit) {
641                    sections.insert(&section.name, true);
642                }
643            }
644        } else {
645            for mapping in &self.spec.handlers {
646                for field_mapping in &mapping.mappings {
647                    if !field_mapping.emit {
648                        continue;
649                    }
650                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
651                    if parts.len() > 1 {
652                        sections.insert(parts[0], true);
653                    }
654                }
655            }
656        }
657
658        let mut fields = Vec::new();
659
660        for section in sections.keys() {
661            if !is_root_section(section) {
662                let base_name = if self.entity_name.contains("Game") {
663                    "Game"
664                } else {
665                    &self.entity_name
666                };
667                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
668                fields.push(TypeScriptField::patch(
669                    section.to_string(),
670                    section_interface_name,
671                    false,
672                ));
673            }
674        }
675
676        for section in &self.spec.sections {
677            if is_root_section(&section.name) {
678                for field in &section.fields {
679                    if !field.emit {
680                        continue;
681                    }
682                    fields.push(TypeScriptField::from_names(
683                        field.raw_field_name().to_string(),
684                        field.canonical_field_name(),
685                        self.field_type_info_to_typescript(field),
686                        field.is_optional,
687                        FieldPresence::Patch,
688                    ));
689                }
690            }
691        }
692
693        fields
694    }
695
696    fn generate_schema_for_fields(
697        &self,
698        name: &str,
699        fields: &[TypeScriptField],
700        required: bool,
701        mode: SchemaMode,
702        patch_schema_types: &HashSet<String>,
703    ) -> String {
704        if fields.is_empty() {
705            return format!(
706                "export const {} = z.object({{}});",
707                schema_constant_name(name, mode)
708            );
709        }
710
711        let mut field_definitions = Vec::new();
712        let mut transform_fields = Vec::new();
713
714        for field in fields {
715            let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
716                self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
717            });
718            let with_nullable = if field.nullable {
719                format!("{}.nullable()", base_schema)
720            } else {
721                base_schema
722            };
723            let schema = if required || matches!(field.presence, FieldPresence::Required) {
724                with_nullable
725            } else {
726                format!("{}.optional()", with_nullable)
727            };
728
729            field_definitions.push(format!("  {}: {},", field.raw_name, schema));
730            if mode == SchemaMode::Patch {
731                transform_fields.push(format!(
732                    "  ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
733                    raw_name = field.raw_name,
734                    field_name = field.name,
735                ));
736            } else {
737                transform_fields.push(format!("  {}: value.{},", field.name, field.raw_name));
738            }
739        }
740
741        format!(
742            "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
743            schema_constant_name(name, mode),
744            field_definitions.join("\n"),
745            transform_fields.join("\n")
746        )
747    }
748
749    fn generate_completed_entity_schema(
750        &self,
751        entity_name: &str,
752        patch_schema_types: &HashSet<String>,
753    ) -> String {
754        let main_fields = self.collect_main_entity_fields();
755        self.generate_schema_for_fields(
756            &format!("{}Completed", entity_name),
757            &main_fields,
758            true,
759            SchemaMode::Canonical,
760            patch_schema_types,
761        )
762    }
763
764    fn generate_resolved_type_schemas(
765        &self,
766        patch_schema_types: &HashSet<String>,
767    ) -> Vec<(String, String)> {
768        let mut schemas = Vec::new();
769        let mut generated_types = HashSet::new();
770        let resolved_name_map = self.build_resolved_type_name_map();
771
772        for section in &self.spec.sections {
773            for field_info in &section.fields {
774                if let Some(resolved) = &field_info.resolved_type {
775                    let type_name =
776                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
777
778                    if !generated_types.insert(type_name.clone()) {
779                        continue;
780                    }
781
782                    if resolved.is_enum {
783                        let variants: Vec<String> = resolved
784                            .enum_variants
785                            .iter()
786                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
787                            .collect();
788                        let schema = if variants.is_empty() {
789                            format!("export const {}Schema = z.string();", type_name)
790                        } else {
791                            format!(
792                                "export const {}Schema = z.enum([{}]);",
793                                type_name,
794                                variants.join(", ")
795                            )
796                        };
797                        schemas.push((format!("{}Schema", type_name), schema));
798                        continue;
799                    }
800
801                    let schema = self.generate_schema_for_fields(
802                        &type_name,
803                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
804                        true,
805                        SchemaMode::Canonical,
806                        patch_schema_types,
807                    );
808                    schemas.push((format!("{}Schema", type_name), schema));
809                }
810            }
811        }
812
813        schemas
814    }
815
816    fn generate_resolved_type_patch_schemas(
817        &self,
818        patch_schema_types: &HashSet<String>,
819    ) -> Vec<(String, String)> {
820        let mut schemas = Vec::new();
821        let mut generated_types = HashSet::new();
822        let resolved_name_map = self.build_resolved_type_name_map();
823
824        for section in &self.spec.sections {
825            for field_info in &section.fields {
826                if let Some(resolved) = &field_info.resolved_type {
827                    let type_name =
828                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
829
830                    if !generated_types.insert(type_name.clone()) || resolved.is_enum {
831                        continue;
832                    }
833
834                    let schema = self.generate_schema_for_fields(
835                        &type_name,
836                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
837                        false,
838                        SchemaMode::Patch,
839                        patch_schema_types,
840                    );
841                    schemas.push((format!("{}PatchSchema", type_name), schema));
842                }
843            }
844        }
845
846        schemas
847    }
848
849    fn generate_event_schemas(&self) -> Vec<(String, String)> {
850        let mut schemas = Vec::new();
851        let mut generated_types = HashSet::new();
852
853        let handlers = match &self.handlers_json {
854            Some(h) => h.as_array(),
855            None => return schemas,
856        };
857
858        let handlers_array = match handlers {
859            Some(arr) => arr,
860            None => return schemas,
861        };
862
863        for handler in handlers_array {
864            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
865                for mapping in mappings {
866                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
867                        if target_path.contains(".events.") || target_path.starts_with("events.") {
868                            if let Some(source) = mapping.get("source") {
869                                if let Some(event_data) = self.extract_event_data(source) {
870                                    if let Some(handler_source) = handler.get("source") {
871                                        if let Some(instruction_name) =
872                                            self.extract_instruction_name(handler_source)
873                                        {
874                                            let event_field_name =
875                                                target_path.split('.').next_back().unwrap_or("");
876                                            let interface_name = format!(
877                                                "{}Event",
878                                                to_pascal_case(event_field_name)
879                                            );
880
881                                            if generated_types.insert(interface_name.clone()) {
882                                                if let Some(schema) = self
883                                                    .generate_event_schema_from_idl(
884                                                        &interface_name,
885                                                        &instruction_name,
886                                                        &event_data,
887                                                    )
888                                                {
889                                                    schemas.push((
890                                                        format!("{}Schema", interface_name),
891                                                        schema,
892                                                    ));
893                                                }
894                                            }
895                                        }
896                                    }
897                                }
898                            }
899                        }
900                    }
901                }
902            }
903        }
904
905        schemas
906    }
907
908    fn generate_event_schema_from_idl(
909        &self,
910        interface_name: &str,
911        rust_instruction_name: &str,
912        captured_fields: &[(String, Option<String>)],
913    ) -> Option<String> {
914        if captured_fields.is_empty() {
915            return Some(format!(
916                "export const {}Schema = z.object({{}});",
917                interface_name
918            ));
919        }
920
921        let idl_value = self.idl.as_ref()?;
922        let instructions = idl_value.get("instructions")?.as_array()?;
923
924        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
925        let args = instruction.get("args")?.as_array()?;
926
927        let mut fields = Vec::new();
928        for (field_name, transform) in captured_fields {
929            for arg in args {
930                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
931                    if arg_name == field_name {
932                        if let Some(arg_type) = arg.get("type") {
933                            let ts_type =
934                                self.idl_type_to_typescript(arg_type, transform.as_deref());
935                            fields.push(TypeScriptField::patch(
936                                field_name.to_string(),
937                                ts_type,
938                                false,
939                            ));
940                        }
941                        break;
942                    }
943                }
944            }
945        }
946
947        Some(render_schema_from_ts_fields(interface_name, &fields, true))
948    }
949
950    fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
951        let mut schemas = Vec::new();
952        let mut generated_types = self.already_emitted_types.clone();
953
954        let idl_value = match &self.idl {
955            Some(idl) => idl,
956            None => return schemas,
957        };
958
959        let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
960            Some(types) => types,
961            None => return schemas,
962        };
963
964        for type_def in types_array {
965            if let (Some(type_name), Some(type_obj)) = (
966                type_def.get("name").and_then(|v| v.as_str()),
967                type_def.get("type").and_then(|v| v.as_object()),
968            ) {
969                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
970                    let interface_name = to_pascal_case(type_name);
971                    if !generated_types.insert(interface_name.clone()) {
972                        continue;
973                    }
974                    if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
975                        let variant_names: Vec<String> = variants
976                            .iter()
977                            .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
978                            .map(|s| format!("\"{}\"", to_pascal_case(s)))
979                            .collect();
980
981                        let schema = if variant_names.is_empty() {
982                            format!("export const {}Schema = z.string();", interface_name)
983                        } else {
984                            format!(
985                                "export const {}Schema = z.enum([{}]);",
986                                interface_name,
987                                variant_names.join(", ")
988                            )
989                        };
990                        schemas.push((format!("{}Schema", interface_name), schema));
991                    }
992                }
993            }
994        }
995
996        schemas
997    }
998
999    fn typescript_type_to_zod_for_schema(
1000        &self,
1001        ts_type: &str,
1002        mode: SchemaMode,
1003        patch_schema_types: &HashSet<String>,
1004    ) -> String {
1005        typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1006    }
1007
1008    fn patch_schema_type_names(&self) -> HashSet<String> {
1009        let mut names = HashSet::new();
1010        let resolved_name_map = self.build_resolved_type_name_map();
1011
1012        for section in &self.spec.sections {
1013            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1014                names.insert(self.section_interface_name(&section.name));
1015            }
1016
1017            for field in &section.fields {
1018                let Some(resolved) = &field.resolved_type else {
1019                    continue;
1020                };
1021
1022                if resolved.is_enum {
1023                    continue;
1024                }
1025
1026                names.insert(
1027                    self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1028                );
1029            }
1030        }
1031
1032        names.insert("TokenMetadata".to_string());
1033        names
1034    }
1035
1036    fn generate_stack_definition(&self) -> String {
1037        let stack_name = to_kebab_case(&self.entity_name);
1038        let entity_pascal = to_pascal_case(&self.entity_name);
1039        let export_name = format!(
1040            "{}_{}",
1041            self.entity_name.to_uppercase(),
1042            self.config.export_const_name
1043        );
1044
1045        let view_helpers = self.generate_view_helpers();
1046        let derived_views = self.generate_derived_view_entries();
1047        let schema_names = self.generate_schemas().names;
1048        let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1049        for name in schema_names {
1050            unique_schemas.insert(name);
1051        }
1052        let schemas_block = if unique_schemas.is_empty() {
1053            String::new()
1054        } else {
1055            let schema_entries: Vec<String> = unique_schemas
1056                .iter()
1057                .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1058                .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
1059                .collect();
1060            if schema_entries.is_empty() {
1061                String::new()
1062            } else {
1063                format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
1064            }
1065        };
1066
1067        let patch_schemas_block = format!(
1068            "\n  patchSchemas: {{\n    {entity}: {entity}PatchSchema,\n  }},",
1069            entity = entity_pascal
1070        );
1071
1072        // Generate URL line - either actual URL or placeholder comment
1073        let url_line = match &self.config.url {
1074            Some(url) => format!("  url: '{}',", url),
1075            None => "  url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1076                .to_string(),
1077        };
1078
1079        format!(
1080            r#"{}
1081
1082// ============================================================================
1083// Stack Definition
1084// ============================================================================
1085
1086/** Stack definition for {} */
1087export const {} = {{
1088  name: '{}',
1089{}
1090  views: {{
1091    {}: {{
1092      state: stateView<{}>('{}/state'),
1093      list: listView<{}>('{}/list'),{}
1094    }},
1095  }},{}{}
1096}} as const;
1097
1098/** Type alias for the stack */
1099export type {}Stack = typeof {};
1100
1101/** Default export for convenience */
1102export default {};"#,
1103            view_helpers,
1104            entity_pascal,
1105            export_name,
1106            stack_name,
1107            url_line,
1108            self.entity_name,
1109            entity_pascal,
1110            self.entity_name,
1111            entity_pascal,
1112            self.entity_name,
1113            derived_views,
1114            schemas_block,
1115            patch_schemas_block,
1116            entity_pascal,
1117            export_name,
1118            export_name
1119        )
1120    }
1121
1122    fn generate_derived_view_entries(&self) -> String {
1123        let derived_views: Vec<&ViewDef> = self
1124            .views
1125            .iter()
1126            .filter(|v| {
1127                !v.id.ends_with("/state")
1128                    && !v.id.ends_with("/list")
1129                    && v.id.starts_with(&self.entity_name)
1130            })
1131            .collect();
1132
1133        if derived_views.is_empty() {
1134            return String::new();
1135        }
1136
1137        let entity_pascal = to_pascal_case(&self.entity_name);
1138        let mut entries = Vec::new();
1139
1140        for view in derived_views {
1141            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1142
1143            entries.push(format!(
1144                "\n      {}: listView<{}>('{}'),",
1145                view_name, entity_pascal, view.id
1146            ));
1147        }
1148
1149        entries.join("")
1150    }
1151
1152    fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1153        // First, try to resolve from AST field mappings
1154        if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1155            let ts_type = self.field_type_info_to_typescript(field_info);
1156
1157            // If it's an Append strategy, wrap in array
1158            if matches!(mapping.population, PopulationStrategy::Append) {
1159                return if ts_type.ends_with("[]") {
1160                    ts_type
1161                } else {
1162                    format!("{}[]", ts_type)
1163                };
1164            }
1165
1166            return ts_type;
1167        }
1168
1169        // Fallback to legacy inference
1170        match &mapping.population {
1171            PopulationStrategy::Append => {
1172                // For arrays, try to infer the element type
1173                match &mapping.source {
1174                    MappingSource::AsEvent { .. } => "any[]".to_string(),
1175                    _ => "any[]".to_string(),
1176                }
1177            }
1178            _ => {
1179                // Infer type from source and field name
1180                let base_type = match &mapping.source {
1181                    MappingSource::FromSource { .. } => {
1182                        self.infer_type_from_field_name(&mapping.target_path)
1183                    }
1184                    MappingSource::Constant(value) => value_to_typescript_type(value),
1185                    MappingSource::AsEvent { .. } => "any".to_string(),
1186                    _ => "any".to_string(),
1187                };
1188
1189                // Apply transformations to type
1190                if let Some(transform) = &mapping.transform {
1191                    match transform {
1192                        Transformation::HexEncode | Transformation::HexDecode => {
1193                            "string".to_string()
1194                        }
1195                        Transformation::Base58Encode | Transformation::Base58Decode => {
1196                            "string".to_string()
1197                        }
1198                        Transformation::ToString => "string".to_string(),
1199                        Transformation::ToNumber => "number".to_string(),
1200                    }
1201                } else {
1202                    base_type
1203                }
1204            }
1205        }
1206    }
1207
1208    fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1209        if let Some(resolved) = &field_info.resolved_type {
1210            let interface_name = self.resolved_type_to_interface_name(resolved);
1211
1212            let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1213            {
1214                format!("EventWrapper<{}>", interface_name)
1215            } else {
1216                interface_name
1217            };
1218
1219            let with_array = if field_info.is_array {
1220                format!("{}[]", base_type)
1221            } else {
1222                base_type
1223            };
1224
1225            return with_array;
1226        }
1227
1228        if let Some(inner_type) = &field_info.inner_type {
1229            if is_builtin_resolver_type(inner_type) {
1230                return inner_type.clone();
1231            }
1232        }
1233
1234        if let Some(ts_type) = typescript_integer_type(
1235            field_info.effective_integer_kind(),
1236            field_info
1237                .inner_type
1238                .as_deref()
1239                .or(Some(field_info.rust_type_name.as_str())),
1240        ) {
1241            return if field_info.is_array {
1242                format!("{}[]", ts_type)
1243            } else {
1244                ts_type.to_string()
1245            };
1246        }
1247
1248        if field_info.base_type == BaseType::Any
1249            || (field_info.base_type == BaseType::Array
1250                && field_info.inner_type.as_deref() == Some("Value"))
1251        {
1252            if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1253                return if field_info.is_array {
1254                    format!("{}[]", event_type)
1255                } else {
1256                    event_type
1257                };
1258            }
1259        }
1260
1261        self.base_type_to_typescript(
1262            &field_info.base_type,
1263            field_info.effective_integer_kind(),
1264            field_info.is_array,
1265        )
1266    }
1267
1268    /// Find the generated event interface name for a given field
1269    fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1270        // Use the raw JSON handlers if available
1271        let handlers = self.handlers_json.as_ref()?.as_array()?;
1272
1273        // Look through handlers to find event mappings for this field
1274        for handler in handlers {
1275            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1276                for mapping in mappings {
1277                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1278                        // Check if this mapping targets our field (e.g., "events.created")
1279                        let target_parts: Vec<&str> = target_path.split('.').collect();
1280                        if let Some(target_field) = target_parts.last() {
1281                            if *target_field == field_name {
1282                                // Check if this is an event mapping
1283                                if let Some(source) = mapping.get("source") {
1284                                    if self.extract_event_data(source).is_some() {
1285                                        // Generate the interface name (e.g., "created" -> "CreatedEvent")
1286                                        return Some(format!(
1287                                            "{}Event",
1288                                            to_pascal_case(field_name)
1289                                        ));
1290                                    }
1291                                }
1292                            }
1293                        }
1294                    }
1295                }
1296            }
1297        }
1298        None
1299    }
1300
1301    /// Generate TypeScript interface name from resolved type
1302    fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1303        self.build_resolved_type_name_map()
1304            .get(&resolved.type_name)
1305            .cloned()
1306            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1307    }
1308
1309    /// Generate nested interfaces for all resolved types in the AST
1310    fn generate_nested_interfaces(&self) -> Vec<String> {
1311        let mut interfaces = Vec::new();
1312        let mut generated_types = self.already_emitted_types.clone();
1313        let resolved_name_map = self.build_resolved_type_name_map();
1314
1315        // Collect all resolved types from all sections
1316        for section in &self.spec.sections {
1317            for field_info in &section.fields {
1318                if let Some(resolved) = &field_info.resolved_type {
1319                    let type_name =
1320                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1321
1322                    // Only generate each type once
1323                    if generated_types.insert(type_name) {
1324                        let interface = self.generate_interface_for_resolved_type(resolved);
1325                        interfaces.push(interface);
1326                    }
1327                }
1328            }
1329        }
1330
1331        // Generate event interfaces from instruction handlers
1332        interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1333
1334        // Also generate all enum types from the IDL (even if not directly referenced)
1335        if let Some(idl_value) = &self.idl {
1336            if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1337                for type_def in types_array {
1338                    if let (Some(type_name), Some(type_obj)) = (
1339                        type_def.get("name").and_then(|v| v.as_str()),
1340                        type_def.get("type").and_then(|v| v.as_object()),
1341                    ) {
1342                        if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1343                            // Only generate if not already generated
1344                            let interface_name = to_pascal_case(type_name);
1345                            if generated_types.insert(interface_name.clone()) {
1346                                if let Some(variants) =
1347                                    type_obj.get("variants").and_then(|v| v.as_array())
1348                                {
1349                                    let variant_names: Vec<String> = variants
1350                                        .iter()
1351                                        .filter_map(|v| {
1352                                            v.get("name")
1353                                                .and_then(|n| n.as_str())
1354                                                .map(|s| s.to_string())
1355                                        })
1356                                        .collect();
1357
1358                                    if !variant_names.is_empty() {
1359                                        let variant_strings: Vec<String> = variant_names
1360                                            .iter()
1361                                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
1362                                            .collect();
1363
1364                                        let enum_type = format!(
1365                                            "export type {} = {};",
1366                                            interface_name,
1367                                            variant_strings.join(" | ")
1368                                        );
1369                                        interfaces.push(enum_type);
1370                                    }
1371                                }
1372                            }
1373                        }
1374                    }
1375                }
1376            }
1377        }
1378
1379        interfaces
1380    }
1381
1382    /// Generate TypeScript interfaces for event types from instruction handlers
1383    fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1384        let mut interfaces = Vec::new();
1385
1386        // Use the raw JSON handlers if available
1387        let handlers = match &self.handlers_json {
1388            Some(h) => h.as_array(),
1389            None => return interfaces,
1390        };
1391
1392        let handlers_array = match handlers {
1393            Some(arr) => arr,
1394            None => return interfaces,
1395        };
1396
1397        // Look through handlers to find instruction-based event mappings
1398        for handler in handlers_array {
1399            // Check if this handler has event mappings
1400            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1401                for mapping in mappings {
1402                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1403                        // Check if the target is an event field (contains ".events." or starts with "events.")
1404                        if target_path.contains(".events.") || target_path.starts_with("events.") {
1405                            // Check if the source is AsEvent
1406                            if let Some(source) = mapping.get("source") {
1407                                if let Some(event_data) = self.extract_event_data(source) {
1408                                    // Extract instruction name from handler source
1409                                    if let Some(handler_source) = handler.get("source") {
1410                                        if let Some(instruction_name) =
1411                                            self.extract_instruction_name(handler_source)
1412                                        {
1413                                            // Generate interface name from target path (e.g., "events.created" -> "CreatedEvent")
1414                                            let event_field_name =
1415                                                target_path.split('.').next_back().unwrap_or("");
1416                                            let interface_name = format!(
1417                                                "{}Event",
1418                                                to_pascal_case(event_field_name)
1419                                            );
1420
1421                                            // Only generate once
1422                                            if generated_types.insert(interface_name.clone()) {
1423                                                if let Some(interface) = self
1424                                                    .generate_event_interface_from_idl(
1425                                                        &interface_name,
1426                                                        &instruction_name,
1427                                                        &event_data,
1428                                                    )
1429                                                {
1430                                                    interfaces.push(interface);
1431                                                }
1432                                            }
1433                                        }
1434                                    }
1435                                }
1436                            }
1437                        }
1438                    }
1439                }
1440            }
1441        }
1442
1443        interfaces
1444    }
1445
1446    /// Extract event field data from a mapping source
1447    fn extract_event_data(
1448        &self,
1449        source: &serde_json::Value,
1450    ) -> Option<Vec<(String, Option<String>)>> {
1451        if let Some(as_event) = source.get("AsEvent") {
1452            if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1453                let mut event_fields = Vec::new();
1454                for field in fields {
1455                    if let Some(from_source) = field.get("FromSource") {
1456                        if let Some(path) = from_source
1457                            .get("path")
1458                            .and_then(|p| p.get("segments"))
1459                            .and_then(|s| s.as_array())
1460                        {
1461                            // Get the last segment as the field name (e.g., ["data", "game_id"] -> "game_id")
1462                            if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1463                                let transform = from_source
1464                                    .get("transform")
1465                                    .and_then(|t| t.as_str())
1466                                    .map(|s| s.to_string());
1467                                event_fields.push((field_name.to_string(), transform));
1468                            }
1469                        }
1470                    }
1471                }
1472                return Some(event_fields);
1473            }
1474        }
1475        None
1476    }
1477
1478    /// Extract instruction name from handler source, returning the raw PascalCase name
1479    fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1480        if let Some(source_obj) = source.get("Source") {
1481            if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1482                let instruction_part =
1483                    crate::event_type_helpers::strip_event_type_suffix(type_name);
1484                return Some(instruction_part.to_string());
1485            }
1486        }
1487        None
1488    }
1489
1490    /// Find an instruction in the IDL by name, handling different naming conventions.
1491    /// IDLs may use snake_case (pumpfun: "admin_set_creator") or camelCase (ore: "claimSol").
1492    /// The input name comes from Rust types which are PascalCase ("AdminSetCreator", "ClaimSol").
1493    fn find_instruction_in_idl<'a>(
1494        &self,
1495        instructions: &'a [serde_json::Value],
1496        rust_name: &str,
1497    ) -> Option<&'a serde_json::Value> {
1498        let normalized_search = normalize_for_comparison(rust_name);
1499
1500        for instruction in instructions {
1501            if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1502                if normalize_for_comparison(idl_name) == normalized_search {
1503                    return Some(instruction);
1504                }
1505            }
1506        }
1507        None
1508    }
1509
1510    /// Generate a TypeScript interface for an event from IDL instruction data
1511    fn generate_event_interface_from_idl(
1512        &self,
1513        interface_name: &str,
1514        rust_instruction_name: &str,
1515        captured_fields: &[(String, Option<String>)],
1516    ) -> Option<String> {
1517        if captured_fields.is_empty() {
1518            return Some(format!("export interface {} {{}}", interface_name));
1519        }
1520
1521        let idl_value = self.idl.as_ref()?;
1522        let instructions = idl_value.get("instructions")?.as_array()?;
1523
1524        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1525        let args = instruction.get("args")?.as_array()?;
1526
1527        let mut fields = Vec::new();
1528        for (field_name, transform) in captured_fields {
1529            for arg in args {
1530                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1531                    if arg_name == field_name {
1532                        if let Some(arg_type) = arg.get("type") {
1533                            let ts_type =
1534                                self.idl_type_to_typescript(arg_type, transform.as_deref());
1535                            fields.push(TypeScriptField::patch(
1536                                field_name.to_string(),
1537                                ts_type,
1538                                false,
1539                            ));
1540                        }
1541                        break;
1542                    }
1543                }
1544            }
1545        }
1546
1547        if !fields.is_empty() {
1548            return Some(render_interface_from_ts_fields(
1549                interface_name,
1550                &fields,
1551                true,
1552            ));
1553        }
1554
1555        None
1556    }
1557
1558    /// Convert an IDL type (from JSON) to TypeScript, considering transforms
1559    fn idl_type_to_typescript(
1560        &self,
1561        idl_type: &serde_json::Value,
1562        transform: Option<&str>,
1563    ) -> String {
1564        #![allow(clippy::only_used_in_recursion)]
1565        // If there's a HexEncode transform, the result is always a string
1566        if transform == Some("HexEncode") {
1567            return "string".to_string();
1568        }
1569
1570        // Handle different IDL type formats
1571        if let Some(type_str) = idl_type.as_str() {
1572            return match type_str {
1573                "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1574                "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1575                "f32" | "f64" => "number".to_string(),
1576                "bool" => "boolean".to_string(),
1577                "string" => "string".to_string(),
1578                "pubkey" | "publicKey" => "string".to_string(),
1579                "bytes" => "string".to_string(),
1580                _ => "any".to_string(),
1581            };
1582        }
1583
1584        // Handle complex types (option, vec, etc.)
1585        if let Some(type_obj) = idl_type.as_object() {
1586            if let Some(option_type) = type_obj.get("option") {
1587                let inner = self.idl_type_to_typescript(option_type, None);
1588                return format!("{} | null", inner);
1589            }
1590            if let Some(vec_type) = type_obj.get("vec") {
1591                let inner = self.idl_type_to_typescript(vec_type, None);
1592                return format!("{}[]", inner);
1593            }
1594        }
1595
1596        "any".to_string()
1597    }
1598
1599    /// Generate a TypeScript interface from a resolved struct type
1600    fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1601        let interface_name = self.resolved_type_to_interface_name(resolved);
1602
1603        // Handle enums as TypeScript union types
1604        if resolved.is_enum {
1605            let variants: Vec<String> = resolved
1606                .enum_variants
1607                .iter()
1608                .map(|v| format!("\"{}\"", to_pascal_case(v)))
1609                .collect();
1610
1611            return format!("export type {} = {};", interface_name, variants.join(" | "));
1612        }
1613
1614        render_interface_from_ts_fields(
1615            &interface_name,
1616            &self.resolved_fields_to_typescript_fields(&resolved.fields),
1617            true,
1618        )
1619    }
1620
1621    /// Convert a resolved field to TypeScript type
1622    fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1623        if let Some(ts_type) =
1624            typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1625        {
1626            return if field.is_array {
1627                format!("{}[]", ts_type)
1628            } else {
1629                ts_type.to_string()
1630            };
1631        }
1632        let base_ts =
1633            self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1634
1635        if field.is_array {
1636            format!("{}[]", base_ts)
1637        } else {
1638            base_ts
1639        }
1640    }
1641
1642    fn resolved_fields_to_typescript_fields(
1643        &self,
1644        fields: &[ResolvedField],
1645    ) -> Vec<TypeScriptField> {
1646        fields
1647            .iter()
1648            .map(|field| {
1649                TypeScriptField::from_names(
1650                    field.raw_field_name().to_string(),
1651                    field.canonical_field_name(),
1652                    self.resolved_field_to_typescript(field),
1653                    field.is_optional,
1654                    FieldPresence::Patch,
1655                )
1656            })
1657            .collect()
1658    }
1659
1660    /// Check if the spec has any event types
1661    fn has_event_types(&self) -> bool {
1662        for section in &self.spec.sections {
1663            for field_info in &section.fields {
1664                if let Some(resolved) = &field_info.resolved_type {
1665                    if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1666                        return true;
1667                    }
1668                }
1669            }
1670        }
1671        false
1672    }
1673
1674    fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1675        let mut reserved_names = self.already_emitted_types.clone();
1676        reserved_names.insert(to_pascal_case(&self.entity_name));
1677
1678        for section in &self.spec.sections {
1679            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1680                reserved_names.insert(self.section_interface_name(&section.name));
1681            }
1682        }
1683
1684        let mut resolved_name_map = HashMap::new();
1685
1686        for section in &self.spec.sections {
1687            for field_info in &section.fields {
1688                if !field_info.emit {
1689                    continue;
1690                }
1691
1692                let Some(resolved) = &field_info.resolved_type else {
1693                    continue;
1694                };
1695
1696                if resolved_name_map.contains_key(&resolved.type_name) {
1697                    continue;
1698                }
1699
1700                let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1701                resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
1702            }
1703        }
1704
1705        resolved_name_map
1706    }
1707
1708    fn resolved_type_to_interface_name_with_map(
1709        &self,
1710        resolved: &ResolvedStructType,
1711        resolved_name_map: &HashMap<String, String>,
1712    ) -> String {
1713        resolved_name_map
1714            .get(&resolved.type_name)
1715            .cloned()
1716            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1717    }
1718
1719    /// Generate the EventWrapper interface
1720    fn generate_event_wrapper_interface(&self) -> String {
1721        r#"/**
1722 * Wrapper for event data that includes context metadata.
1723 * Events are automatically wrapped in this structure at runtime.
1724 */
1725export interface EventWrapper<T> {
1726  /** Unix timestamp when the event was processed */
1727  timestamp: number;
1728  /** The event-specific data */
1729  data: T;
1730  /** Optional blockchain slot number */
1731  slot?: number;
1732  /** Optional transaction signature */
1733  signature?: string;
1734}"#
1735        .to_string()
1736    }
1737
1738    fn infer_type_from_field_name(&self, field_name: &str) -> String {
1739        let lower_name = field_name.to_lowercase();
1740
1741        // Special case for event fields - these are typically Option<Value> and should be 'any'
1742        if lower_name.contains("events.") {
1743            // For fields in the events section, default to 'any' since they're typically Option<Value>
1744            return "any".to_string();
1745        }
1746
1747        // Common patterns for type inference
1748        if lower_name.contains("id")
1749            || lower_name.contains("count")
1750            || lower_name.contains("number")
1751            || lower_name.contains("timestamp")
1752            || lower_name.contains("time")
1753            || lower_name.contains("at")
1754            || lower_name.contains("volume")
1755            || lower_name.contains("amount")
1756            || lower_name.contains("ev")
1757            || lower_name.contains("fee")
1758            || lower_name.contains("payout")
1759            || lower_name.contains("distributed")
1760            || lower_name.contains("claimable")
1761            || lower_name.contains("total")
1762            || lower_name.contains("rate")
1763            || lower_name.contains("ratio")
1764            || lower_name.contains("current")
1765            || lower_name.contains("state")
1766        {
1767            "number".to_string()
1768        } else if lower_name.contains("status")
1769            || lower_name.contains("hash")
1770            || lower_name.contains("address")
1771            || lower_name.contains("key")
1772        {
1773            "string".to_string()
1774        } else {
1775            "any".to_string()
1776        }
1777    }
1778
1779    fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
1780        // Stream mappings produce patch-shaped objects, so this bool only captures
1781        // whether the field can be explicitly null in the payload.
1782        match &mapping.source {
1783            // Constants are typically non-optional
1784            MappingSource::Constant(_) => false,
1785            // Events are typically optional (Option<Value>)
1786            MappingSource::AsEvent { .. } => true,
1787            // For source fields, default to optional since most Rust fields are Option<T>
1788            MappingSource::FromSource { .. } => true,
1789            // Other cases default to optional
1790            _ => true,
1791        }
1792    }
1793
1794    /// Convert language-agnostic base types to TypeScript types
1795    fn base_type_to_typescript(
1796        &self,
1797        base_type: &BaseType,
1798        integer_kind: Option<IntegerKind>,
1799        is_array: bool,
1800    ) -> String {
1801        let base_ts_type = match base_type {
1802            BaseType::Integer => integer_kind
1803                .map(integer_kind_to_typescript)
1804                .unwrap_or("number"),
1805            BaseType::Float => "number",
1806            BaseType::String => "string",
1807            BaseType::Boolean => "boolean",
1808            BaseType::Timestamp => integer_kind
1809                .map(integer_kind_to_typescript)
1810                .unwrap_or("number"),
1811            BaseType::Binary => "string", // Base64 encoded strings
1812            BaseType::Pubkey => "string", // Solana public keys as Base58 strings
1813            BaseType::Array => "any[]",   // Default array type
1814            BaseType::Object => "Record<string, any>", // Generic object
1815            BaseType::Any => "any",
1816        };
1817
1818        if is_array && !matches!(base_type, BaseType::Array) {
1819            format!("{}[]", base_ts_type)
1820        } else {
1821            base_ts_type.to_string()
1822        }
1823    }
1824}
1825
1826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827enum FieldPresence {
1828    Patch,
1829    Required,
1830}
1831
1832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1833enum SchemaMode {
1834    Canonical,
1835    Patch,
1836}
1837
1838fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
1839    match mode {
1840        SchemaMode::Canonical => format!("{}Schema", name),
1841        SchemaMode::Patch => format!("{}PatchSchema", name),
1842    }
1843}
1844
1845fn localize_section_field_names(
1846    section_name: &str,
1847    field_info: &FieldTypeInfo,
1848) -> (String, String) {
1849    let raw_name = field_info.raw_field_name();
1850    if is_root_section(section_name) {
1851        return (raw_name.to_string(), field_info.canonical_field_name());
1852    }
1853
1854    let prefix = format!("{}.", section_name);
1855    if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
1856        return (local_raw_name.to_string(), to_camel_case(local_raw_name));
1857    }
1858
1859    (raw_name.to_string(), field_info.canonical_field_name())
1860}
1861
1862/// Represents a TypeScript field in an interface
1863#[derive(Debug, Clone)]
1864struct TypeScriptField {
1865    name: String,
1866    raw_name: String,
1867    ts_type: String,
1868    nullable: bool,
1869    presence: FieldPresence,
1870    zod_schema: Option<String>,
1871    #[allow(dead_code)]
1872    description: Option<String>,
1873}
1874
1875impl TypeScriptField {
1876    fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
1877        Self::from_names(
1878            raw_name.clone(),
1879            to_camel_case(&raw_name),
1880            ts_type,
1881            nullable,
1882            FieldPresence::Patch,
1883        )
1884    }
1885
1886    fn required_with_schema(
1887        raw_name: String,
1888        canonical_name: String,
1889        ts_type: String,
1890        nullable: bool,
1891        zod_schema: String,
1892    ) -> Self {
1893        let mut field = Self::from_names(
1894            raw_name,
1895            canonical_name,
1896            ts_type,
1897            nullable,
1898            FieldPresence::Required,
1899        );
1900        field.zod_schema = Some(zod_schema);
1901        field
1902    }
1903
1904    fn from_names(
1905        raw_name: String,
1906        canonical_name: String,
1907        ts_type: String,
1908        nullable: bool,
1909        presence: FieldPresence,
1910    ) -> Self {
1911        Self {
1912            name: canonical_name,
1913            raw_name,
1914            ts_type,
1915            nullable,
1916            presence,
1917            zod_schema: None,
1918            description: None,
1919        }
1920    }
1921
1922    fn rendered_ts_type(&self) -> String {
1923        if self.nullable {
1924            format!("{} | null", self.ts_type)
1925        } else {
1926            self.ts_type.clone()
1927        }
1928    }
1929}
1930
1931#[derive(Debug, Clone)]
1932struct SchemaOutput {
1933    definitions: String,
1934    names: Vec<String>,
1935}
1936
1937#[derive(Debug, Default)]
1938struct IdlAccountArtifacts {
1939    code: String,
1940    schema_names: Vec<String>,
1941    type_names: HashSet<String>,
1942    account_type_names: BTreeMap<(String, String), String>,
1943}
1944
1945/// Convert serde_json::Value to TypeScript type string
1946fn value_to_typescript_type(value: &serde_json::Value) -> String {
1947    match value {
1948        serde_json::Value::Number(_) => "number".to_string(),
1949        serde_json::Value::String(_) => "string".to_string(),
1950        serde_json::Value::Bool(_) => "boolean".to_string(),
1951        serde_json::Value::Array(_) => "any[]".to_string(),
1952        serde_json::Value::Object(_) => "Record<string, any>".to_string(),
1953        serde_json::Value::Null => "null".to_string(),
1954    }
1955}
1956
1957fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
1958    let mut names = HashSet::new();
1959    let registry = crate::resolvers::builtin_resolver_registry();
1960    for resolver in registry.definitions() {
1961        let output_type = resolver.output_type();
1962        for section in &spec.sections {
1963            for field in &section.fields {
1964                if field.inner_type.as_deref() == Some(output_type) {
1965                    names.insert(output_type.to_string());
1966                }
1967            }
1968        }
1969    }
1970    names
1971}
1972
1973fn generate_idl_account_artifacts(
1974    idls: &[IdlSnapshot],
1975    reserved_type_names: &HashSet<String>,
1976) -> IdlAccountArtifacts {
1977    let mut used_type_names = reserved_type_names.clone();
1978    let mut emitted_type_names = HashSet::new();
1979    let mut seen_schema_names = HashSet::new();
1980    let mut interface_blocks = Vec::new();
1981    let mut schema_blocks = Vec::new();
1982    let mut schema_names = Vec::new();
1983    let mut account_type_names = BTreeMap::new();
1984
1985    for idl in idls {
1986        let program_key = to_camel_case(&idl.name);
1987        let program_prefix = to_pascal_case(&idl.name);
1988        let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
1989            .types
1990            .iter()
1991            .map(|type_def| (type_def.name.clone(), type_def))
1992            .collect();
1993        let account_names: HashSet<String> = idl
1994            .accounts
1995            .iter()
1996            .map(|account| account.name.clone())
1997            .collect();
1998        let mut local_name_map = BTreeMap::new();
1999
2000        for account in &idl.accounts {
2001            let unique_name =
2002                unique_idl_type_name(&account.name, &program_prefix, &mut used_type_names);
2003            emitted_type_names.insert(unique_name.clone());
2004            local_name_map.insert(account.name.clone(), unique_name.clone());
2005            account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2006        }
2007
2008        let mut required_defined_types = BTreeSet::new();
2009        for account in &idl.accounts {
2010            for field in resolve_idl_account_fields(account, &type_defs) {
2011                collect_required_defined_types(
2012                    &field.type_,
2013                    &type_defs,
2014                    &account_names,
2015                    &mut required_defined_types,
2016                );
2017            }
2018        }
2019
2020        for type_name in &required_defined_types {
2021            if local_name_map.contains_key(type_name) {
2022                continue;
2023            }
2024            let unique_name =
2025                unique_idl_type_name(type_name, &program_prefix, &mut used_type_names);
2026            emitted_type_names.insert(unique_name.clone());
2027            local_name_map.insert(type_name.clone(), unique_name);
2028        }
2029
2030        for type_name in &required_defined_types {
2031            if account_names.contains(type_name) {
2032                continue;
2033            }
2034            if let Some(type_def) = type_defs.get(type_name) {
2035                if let Some((interface_def, schema_name, schema_def)) =
2036                    generate_type_defs_from_idl_type(type_def, &local_name_map)
2037                {
2038                    interface_blocks.push(interface_def);
2039                    if seen_schema_names.insert(schema_name.clone()) {
2040                        schema_names.push(schema_name.clone());
2041                        schema_blocks.push(schema_def);
2042                    }
2043                }
2044            }
2045        }
2046
2047        for account in &idl.accounts {
2048            let Some(type_name) = local_name_map.get(&account.name) else {
2049                continue;
2050            };
2051            let account_fields = resolve_idl_account_fields(account, &type_defs);
2052            interface_blocks.push(generate_interface_from_idl_fields(
2053                type_name,
2054                account_fields,
2055                &local_name_map,
2056            ));
2057            let schema_name = format!("{}Schema", type_name);
2058            if seen_schema_names.insert(schema_name.clone()) {
2059                schema_names.push(schema_name.clone());
2060                schema_blocks.push(generate_schema_from_idl_fields(
2061                    type_name,
2062                    account_fields,
2063                    &local_name_map,
2064                ));
2065            }
2066        }
2067    }
2068
2069    let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2070        String::new()
2071    } else if schema_blocks.is_empty() {
2072        interface_blocks.join("\n\n")
2073    } else if interface_blocks.is_empty() {
2074        schema_blocks.join("\n\n")
2075    } else {
2076        format!(
2077            "{}\n\n{}",
2078            interface_blocks.join("\n\n"),
2079            schema_blocks.join("\n\n")
2080        )
2081    };
2082
2083    IdlAccountArtifacts {
2084        code,
2085        schema_names,
2086        type_names: emitted_type_names,
2087        account_type_names,
2088    }
2089}
2090
2091fn resolve_idl_account_fields<'a>(
2092    account: &'a IdlAccountSnapshot,
2093    type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2094) -> &'a [IdlFieldSnapshot] {
2095    if !account.fields.is_empty() {
2096        return &account.fields;
2097    }
2098
2099    let Some(type_def) = type_defs.get(&account.name) else {
2100        return &account.fields;
2101    };
2102
2103    match &type_def.type_def {
2104        IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2105        _ => &account.fields,
2106    }
2107}
2108
2109fn unique_idl_type_name(
2110    raw_name: &str,
2111    program_prefix: &str,
2112    used_type_names: &mut HashSet<String>,
2113) -> String {
2114    let base_name = to_pascal_case(raw_name);
2115    if used_type_names.insert(base_name.clone()) {
2116        return base_name;
2117    }
2118
2119    let prefixed = format!("{}{}", program_prefix, base_name);
2120    if used_type_names.insert(prefixed.clone()) {
2121        return prefixed;
2122    }
2123
2124    let mut index = 2;
2125    loop {
2126        let candidate = format!("{}{}", prefixed, index);
2127        if used_type_names.insert(candidate.clone()) {
2128            return candidate;
2129        }
2130        index += 1;
2131    }
2132}
2133
2134fn collect_required_defined_types(
2135    idl_type: &IdlTypeSnapshot,
2136    type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2137    account_names: &HashSet<String>,
2138    output: &mut BTreeSet<String>,
2139) {
2140    match idl_type {
2141        IdlTypeSnapshot::Simple(_) => {}
2142        IdlTypeSnapshot::Array(array_type) => {
2143            for element in &array_type.array {
2144                if let IdlArrayElementSnapshot::Type(inner) = element {
2145                    collect_required_defined_types(inner, type_defs, account_names, output);
2146                }
2147            }
2148        }
2149        IdlTypeSnapshot::Option(option_type) => {
2150            collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2151        }
2152        IdlTypeSnapshot::Vec(vec_type) => {
2153            collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2154        }
2155        IdlTypeSnapshot::HashMap(hash_map_type) => {
2156            collect_required_defined_types(
2157                &hash_map_type.hash_map.0,
2158                type_defs,
2159                account_names,
2160                output,
2161            );
2162            collect_required_defined_types(
2163                &hash_map_type.hash_map.1,
2164                type_defs,
2165                account_names,
2166                output,
2167            );
2168        }
2169        IdlTypeSnapshot::Defined(defined_type) => {
2170            let type_name = match &defined_type.defined {
2171                IdlDefinedInnerSnapshot::Named { name } => name,
2172                IdlDefinedInnerSnapshot::Simple(name) => name,
2173            };
2174
2175            if !output.insert(type_name.clone()) {
2176                return;
2177            }
2178
2179            if account_names.contains(type_name) {
2180                return;
2181            }
2182
2183            let Some(type_def) = type_defs.get(type_name) else {
2184                return;
2185            };
2186
2187            match &type_def.type_def {
2188                IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2189                    for field in fields {
2190                        collect_required_defined_types(
2191                            &field.type_,
2192                            type_defs,
2193                            account_names,
2194                            output,
2195                        );
2196                    }
2197                }
2198                IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2199                    for field in fields {
2200                        collect_required_defined_types(field, type_defs, account_names, output);
2201                    }
2202                }
2203                IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2204                    for variant in variants {
2205                        for field in &variant.fields {
2206                            match field {
2207                                IdlEnumVariantFieldSnapshot::Named(named) => {
2208                                    collect_required_defined_types(
2209                                        &named.type_,
2210                                        type_defs,
2211                                        account_names,
2212                                        output,
2213                                    );
2214                                }
2215                                IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2216                                    collect_required_defined_types(
2217                                        tuple,
2218                                        type_defs,
2219                                        account_names,
2220                                        output,
2221                                    );
2222                                }
2223                            }
2224                        }
2225                    }
2226                }
2227            }
2228        }
2229    }
2230}
2231
2232fn generate_type_defs_from_idl_type(
2233    type_def: &IdlTypeDefSnapshot,
2234    local_name_map: &BTreeMap<String, String>,
2235) -> Option<(String, String, String)> {
2236    let type_name = local_name_map
2237        .get(&type_def.name)
2238        .cloned()
2239        .unwrap_or_else(|| to_pascal_case(&type_def.name));
2240    let schema_name = format!("{}Schema", type_name);
2241
2242    match &type_def.type_def {
2243        IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2244            generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2245            schema_name,
2246            generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2247        )),
2248        IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2249            let interface = format!(
2250                "export type {} = [{}];",
2251                type_name,
2252                fields
2253                    .iter()
2254                    .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2255                    .collect::<Vec<_>>()
2256                    .join(", ")
2257            );
2258            let schema = format!(
2259                "export const {} = z.tuple([{}]);",
2260                schema_name,
2261                fields
2262                    .iter()
2263                    .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2264                    .collect::<Vec<_>>()
2265                    .join(", ")
2266            );
2267            Some((interface, schema_name, schema))
2268        }
2269        IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2270            let variant_names = variants
2271                .iter()
2272                .map(|variant| format!("\"{}\"", variant.name))
2273                .collect::<Vec<_>>();
2274            let interface = if variant_names.is_empty() {
2275                format!("export type {} = string;", type_name)
2276            } else {
2277                format!("export type {} = {};", type_name, variant_names.join(" | "))
2278            };
2279            let schema = if variant_names.is_empty() {
2280                format!("export const {} = z.string();", schema_name)
2281            } else {
2282                format!(
2283                    "export const {} = z.enum([{}]);",
2284                    schema_name,
2285                    variant_names.join(", ")
2286                )
2287            };
2288            Some((interface, schema_name, schema))
2289        }
2290    }
2291}
2292
2293fn generate_interface_from_idl_fields(
2294    name: &str,
2295    fields: &[IdlFieldSnapshot],
2296    local_name_map: &BTreeMap<String, String>,
2297) -> String {
2298    render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2299}
2300
2301fn generate_schema_from_idl_fields(
2302    name: &str,
2303    fields: &[IdlFieldSnapshot],
2304    local_name_map: &BTreeMap<String, String>,
2305) -> String {
2306    render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2307}
2308
2309fn normalize_idl_fields(
2310    fields: &[IdlFieldSnapshot],
2311    local_name_map: &BTreeMap<String, String>,
2312) -> Vec<TypeScriptField> {
2313    let mut canonical_names = BTreeMap::new();
2314    let mut normalized = Vec::with_capacity(fields.len());
2315
2316    for field in fields {
2317        let canonical_name = to_camel_case(&field.name);
2318        if let Some(existing_source) =
2319            canonical_names.insert(canonical_name.clone(), field.name.clone())
2320        {
2321            assert_eq!(
2322                existing_source, field.name,
2323                "IDL field normalization collision: '{}' and '{}' both normalize to '{}'",
2324                existing_source, field.name, canonical_name
2325            );
2326        }
2327
2328        let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2329
2330        normalized.push(TypeScriptField::required_with_schema(
2331            idl_field_wire_name(&field.name),
2332            canonical_name,
2333            idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2334            nullable,
2335            idl_snapshot_type_to_zod(normalized_type, local_name_map),
2336        ));
2337    }
2338
2339    normalized
2340}
2341
2342fn idl_field_wire_name(field_name: &str) -> String {
2343    idl_to_snake_case(field_name)
2344}
2345
2346fn idl_snapshot_type_to_typescript(
2347    idl_type: &IdlTypeSnapshot,
2348    local_name_map: &BTreeMap<String, String>,
2349) -> String {
2350    match idl_type {
2351        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2352            "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2353            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2354            "bool" => "boolean".to_string(),
2355            "string" => "string".to_string(),
2356            "pubkey" | "publicKey" => "string".to_string(),
2357            "bytes" => "number[]".to_string(),
2358            _ => "any".to_string(),
2359        },
2360        IdlTypeSnapshot::Array(array_type) => {
2361            let (inner_ts, size) = match array_type.array.as_slice() {
2362                [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2363                    Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2364                    Some(*size),
2365                ),
2366                [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2367                    (
2368                        Some(idl_snapshot_type_to_typescript(
2369                            &IdlTypeSnapshot::Simple(inner.clone()),
2370                            local_name_map,
2371                        )),
2372                        Some(*size),
2373                    )
2374                }
2375                _ => (None, None),
2376            };
2377            let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2378            if let Some(size) = size {
2379                format!(
2380                    "{}[]",
2381                    if size == 0 {
2382                        "never".to_string()
2383                    } else {
2384                        inner_ts
2385                    }
2386                )
2387            } else {
2388                format!("{}[]", inner_ts)
2389            }
2390        }
2391        IdlTypeSnapshot::Option(option_type) => {
2392            format!(
2393                "{} | null",
2394                idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2395            )
2396        }
2397        IdlTypeSnapshot::Vec(vec_type) => {
2398            format!(
2399                "{}[]",
2400                idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2401            )
2402        }
2403        IdlTypeSnapshot::HashMap(hash_map_type) => {
2404            format!(
2405                "Record<string, {}>",
2406                idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2407            )
2408        }
2409        IdlTypeSnapshot::Defined(defined_type) => {
2410            let type_name = match &defined_type.defined {
2411                IdlDefinedInnerSnapshot::Named { name } => name,
2412                IdlDefinedInnerSnapshot::Simple(name) => name,
2413            };
2414            local_name_map
2415                .get(type_name)
2416                .cloned()
2417                .unwrap_or_else(|| to_pascal_case(type_name))
2418        }
2419    }
2420}
2421
2422fn idl_snapshot_type_to_zod(
2423    idl_type: &IdlTypeSnapshot,
2424    local_name_map: &BTreeMap<String, String>,
2425) -> String {
2426    match idl_type {
2427        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2428            "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2429            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2430            "bool" => "z.boolean()".to_string(),
2431            "string" => "z.string()".to_string(),
2432            "pubkey" | "publicKey" => "z.string()".to_string(),
2433            "bytes" => "z.array(z.number())".to_string(),
2434            _ => "z.any()".to_string(),
2435        },
2436        IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2437            [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2438                format!(
2439                    "z.array({}).length({})",
2440                    idl_snapshot_type_to_zod(inner, local_name_map),
2441                    size
2442                )
2443            }
2444            [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2445                format!(
2446                    "z.array({}).length({})",
2447                    idl_snapshot_type_to_zod(
2448                        &IdlTypeSnapshot::Simple(inner.clone()),
2449                        local_name_map
2450                    ),
2451                    size
2452                )
2453            }
2454            _ => "z.array(z.any())".to_string(),
2455        },
2456        IdlTypeSnapshot::Option(option_type) => {
2457            format!(
2458                "{}.nullable()",
2459                idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2460            )
2461        }
2462        IdlTypeSnapshot::Vec(vec_type) => {
2463            format!(
2464                "z.array({})",
2465                idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2466            )
2467        }
2468        IdlTypeSnapshot::HashMap(hash_map_type) => {
2469            format!(
2470                "z.record({})",
2471                idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2472            )
2473        }
2474        IdlTypeSnapshot::Defined(defined_type) => {
2475            let type_name = match &defined_type.defined {
2476                IdlDefinedInnerSnapshot::Named { name } => name,
2477                IdlDefinedInnerSnapshot::Simple(name) => name,
2478            };
2479            let resolved_name = local_name_map
2480                .get(type_name)
2481                .cloned()
2482                .unwrap_or_else(|| to_pascal_case(type_name));
2483            format!("z.lazy(() => {}Schema)", resolved_name)
2484        }
2485    }
2486}
2487
2488fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
2489    IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
2490}
2491
2492fn typescript_integer_type(
2493    integer_kind: Option<IntegerKind>,
2494    rust_type: Option<&str>,
2495) -> Option<&'static str> {
2496    integer_kind
2497        .map(integer_kind_to_typescript)
2498        .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
2499}
2500
2501fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
2502    if integer_kind.is_bigint() {
2503        "bigint"
2504    } else {
2505        "number"
2506    }
2507}
2508
2509fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
2510    let mut nullable = false;
2511    while let IdlTypeSnapshot::Option(option_type) = idl_type {
2512        nullable = true;
2513        idl_type = &option_type.option;
2514    }
2515    (idl_type, nullable)
2516}
2517
2518fn typescript_type_to_zod_static(ts_type: &str) -> String {
2519    let trimmed = ts_type.trim();
2520
2521    if let Some(inner) = trimmed.strip_suffix("[]") {
2522        return format!("z.array({})", typescript_type_to_zod_static(inner));
2523    }
2524
2525    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2526        if let Some(inner) = inner.strip_suffix('>') {
2527            return format!(
2528                "EventWrapperSchema({})",
2529                typescript_type_to_zod_static(inner)
2530            );
2531        }
2532    }
2533
2534    match trimmed {
2535        "string" => "z.string()".to_string(),
2536        "number" => "z.number()".to_string(),
2537        "bigint" => bigint_zod(),
2538        "boolean" => "z.boolean()".to_string(),
2539        "any" => "z.any()".to_string(),
2540        "Record<string, any>" => "z.record(z.any())".to_string(),
2541        _ => format!("{}Schema", trimmed),
2542    }
2543}
2544
2545fn typescript_type_to_zod_for_schema_static(
2546    ts_type: &str,
2547    mode: SchemaMode,
2548    patch_schema_types: &HashSet<String>,
2549) -> String {
2550    let trimmed = ts_type.trim();
2551
2552    if let Some(inner) = trimmed.strip_suffix("[]") {
2553        return format!(
2554            "z.array({})",
2555            typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2556        );
2557    }
2558
2559    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2560        if let Some(inner) = inner.strip_suffix('>') {
2561            return format!(
2562                "EventWrapperSchema({})",
2563                typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2564            );
2565        }
2566    }
2567
2568    match trimmed {
2569        "string" => "z.string()".to_string(),
2570        "number" => "z.number()".to_string(),
2571        "bigint" => bigint_zod(),
2572        "boolean" => "z.boolean()".to_string(),
2573        "any" => "z.any()".to_string(),
2574        "Record<string, any>" => "z.record(z.any())".to_string(),
2575        _ => {
2576            if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
2577                format!("{}PatchSchema", trimmed)
2578            } else {
2579                format!("{}Schema", trimmed)
2580            }
2581        }
2582    }
2583}
2584
2585fn render_interface_from_ts_fields(
2586    name: &str,
2587    fields: &[TypeScriptField],
2588    force_required: bool,
2589) -> String {
2590    if fields.is_empty() {
2591        return format!("export interface {} {{\n}}", name);
2592    }
2593
2594    let field_definitions = fields
2595        .iter()
2596        .map(|field| {
2597            let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
2598                ""
2599            } else {
2600                "?"
2601            };
2602            format!(
2603                "  {}{}: {};",
2604                field.name,
2605                optional,
2606                field.rendered_ts_type()
2607            )
2608        })
2609        .collect::<Vec<_>>();
2610
2611    format!(
2612        "export interface {} {{\n{}\n}}",
2613        name,
2614        field_definitions.join("\n")
2615    )
2616}
2617
2618fn render_schema_from_ts_fields(
2619    name: &str,
2620    fields: &[TypeScriptField],
2621    force_required: bool,
2622) -> String {
2623    if fields.is_empty() {
2624        return format!("export const {}Schema = z.object({{}});", name);
2625    }
2626
2627    let field_definitions = fields
2628        .iter()
2629        .map(|field| {
2630            let base_schema = field
2631                .zod_schema
2632                .clone()
2633                .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
2634            let with_nullable = if field.nullable {
2635                format!("{}.nullable()", base_schema)
2636            } else {
2637                base_schema
2638            };
2639            let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
2640                with_nullable
2641            } else {
2642                format!("{}.optional()", with_nullable)
2643            };
2644            format!("  {}: {},", field.raw_name, schema)
2645        })
2646        .collect::<Vec<_>>();
2647
2648    let transform_fields = fields
2649        .iter()
2650        .map(|field| format!("  {}: value.{},", field.name, field.raw_name))
2651        .collect::<Vec<_>>();
2652
2653    format!(
2654        "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
2655        name,
2656        field_definitions.join("\n"),
2657        transform_fields.join("\n")
2658    )
2659}
2660
2661fn bigint_zod() -> String {
2662    "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
2663        .to_string()
2664}
2665
2666fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
2667    let mut names = HashSet::new();
2668    if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
2669        for type_def in types_array {
2670            if let (Some(type_name), Some(type_obj)) = (
2671                type_def.get("name").and_then(|v| v.as_str()),
2672                type_def.get("type").and_then(|v| v.as_object()),
2673            ) {
2674                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
2675                    names.insert(to_pascal_case(type_name));
2676                }
2677            }
2678        }
2679    }
2680    names
2681}
2682
2683/// Extract enum type names that were actually emitted in the generated interfaces.
2684/// Looks for patterns like `export const DirectionKindSchema = z.enum([...])`
2685fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
2686    let mut names = HashSet::new();
2687
2688    // Get all enum type names from the IDL
2689    let idl_enum_names: HashSet<String> = idl
2690        .and_then(|idl| serde_json::to_value(idl).ok())
2691        .map(|v| extract_idl_enum_type_names(&v))
2692        .unwrap_or_default();
2693
2694    // Look for emitted enum schemas in the interfaces
2695    // Pattern: export const DirectionKindSchema = z.enum([...]) or z.string() for empty variants
2696    for line in interfaces.lines() {
2697        if let Some(start) = line.find("export const ") {
2698            let end = line
2699                .find("Schema = z.enum")
2700                .or_else(|| line.find("Schema = z.string()"));
2701            if let Some(end) = end {
2702                let schema_name = line[start + 13..end].trim();
2703                // Check if this schema name corresponds to an IDL enum type
2704                if idl_enum_names.contains(schema_name) {
2705                    names.insert(schema_name.to_string());
2706                }
2707            }
2708        }
2709    }
2710
2711    names
2712}
2713
2714fn unique_resolved_type_name_ts(
2715    resolved: &ResolvedStructType,
2716    reserved_names: &mut HashSet<String>,
2717) -> String {
2718    let base_name = to_pascal_case(&resolved.type_name);
2719    if reserved_names.insert(base_name.clone()) {
2720        return base_name;
2721    }
2722
2723    let suffix = if resolved.is_account {
2724        "Account"
2725    } else if resolved.is_event {
2726        "Event"
2727    } else if resolved.is_instruction {
2728        "Instruction"
2729    } else {
2730        "Type"
2731    };
2732
2733    let preferred = format!("{}{}", base_name, suffix);
2734    if reserved_names.insert(preferred.clone()) {
2735        return preferred;
2736    }
2737
2738    let mut index = 2;
2739    loop {
2740        let candidate = format!("{}{}{}", base_name, suffix, index);
2741        if reserved_names.insert(candidate.clone()) {
2742            return candidate;
2743        }
2744        index += 1;
2745    }
2746}
2747
2748/// Convert snake_case to PascalCase
2749pub(crate) fn to_pascal_case(s: &str) -> String {
2750    s.split(['_', '-', '.'])
2751        .map(|word| {
2752            let mut chars = word.chars();
2753            match chars.next() {
2754                None => String::new(),
2755                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2756            }
2757        })
2758        .collect()
2759}
2760
2761fn to_camel_case(s: &str) -> String {
2762    let pascal = to_pascal_case(s);
2763    let mut chars = pascal.chars();
2764    match chars.next() {
2765        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
2766        None => pascal,
2767    }
2768}
2769
2770/// Normalize a name for case-insensitive comparison across naming conventions.
2771/// Removes underscores and converts to lowercase: "claim_sol", "claimSol", "ClaimSol" all become "claimsol"
2772fn normalize_for_comparison(s: &str) -> String {
2773    s.chars()
2774        .filter(|c| *c != '_')
2775        .flat_map(|c| c.to_lowercase())
2776        .collect()
2777}
2778
2779fn is_root_section(name: &str) -> bool {
2780    name.eq_ignore_ascii_case("root")
2781}
2782
2783fn is_builtin_resolver_type(type_name: &str) -> bool {
2784    crate::resolvers::is_resolver_output_type(type_name)
2785}
2786
2787/// Convert PascalCase/camelCase to kebab-case
2788fn to_kebab_case(s: &str) -> String {
2789    let mut result = String::new();
2790
2791    for ch in s.chars() {
2792        if ch.is_uppercase() && !result.is_empty() {
2793            result.push('-');
2794        }
2795        result.push(ch.to_lowercase().next().unwrap());
2796    }
2797
2798    result
2799}
2800
2801/// CLI-friendly function to generate TypeScript from a spec function
2802/// This will be used by the CLI tool to generate TypeScript from discovered specs
2803pub fn generate_typescript_from_spec_fn<F, S>(
2804    spec_fn: F,
2805    entity_name: String,
2806    config: Option<TypeScriptConfig>,
2807) -> Result<TypeScriptOutput, String>
2808where
2809    F: Fn() -> TypedStreamSpec<S>,
2810{
2811    let spec = spec_fn();
2812    let compiler =
2813        TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
2814
2815    Ok(compiler.compile())
2816}
2817
2818/// Write TypeScript output to a file
2819pub fn write_typescript_to_file(
2820    output: &TypeScriptOutput,
2821    path: &std::path::Path,
2822) -> Result<(), std::io::Error> {
2823    std::fs::write(path, output.full_file())
2824}
2825
2826/// Generate TypeScript from a SerializableStreamSpec (for CLI use)
2827/// This allows the CLI to compile TypeScript without needing the typed spec
2828pub fn compile_serializable_spec(
2829    spec: SerializableStreamSpec,
2830    entity_name: String,
2831    config: Option<TypeScriptConfig>,
2832) -> Result<TypeScriptOutput, String> {
2833    compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
2834}
2835
2836fn compile_serializable_spec_with_emitted(
2837    spec: SerializableStreamSpec,
2838    entity_name: String,
2839    config: Option<TypeScriptConfig>,
2840    already_emitted_types: HashSet<String>,
2841) -> Result<TypeScriptOutput, String> {
2842    let idl = spec
2843        .idl
2844        .as_ref()
2845        .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
2846
2847    let handlers = serde_json::to_value(&spec.handlers).ok();
2848    let views = spec.views.clone();
2849
2850    let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
2851
2852    let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
2853        .with_idl(idl)
2854        .with_handlers_json(handlers)
2855        .with_views(views)
2856        .with_config(config.unwrap_or_default())
2857        .with_already_emitted_types(already_emitted_types);
2858
2859    Ok(compiler.compile())
2860}
2861
2862#[derive(Debug, Clone)]
2863pub struct TypeScriptStackConfig {
2864    pub package_name: String,
2865    pub generate_helpers: bool,
2866    pub export_const_name: String,
2867    pub url: Option<String>,
2868    pub extension_import: Option<String>,
2869}
2870
2871impl Default for TypeScriptStackConfig {
2872    fn default() -> Self {
2873        Self {
2874            package_name: "@usearete/react".to_string(),
2875            generate_helpers: true,
2876            export_const_name: "STACK".to_string(),
2877            url: None,
2878            extension_import: None,
2879        }
2880    }
2881}
2882
2883#[derive(Debug, Clone)]
2884pub struct TypeScriptStackOutput {
2885    pub interfaces: String,
2886    pub stack_definition: String,
2887    pub imports: String,
2888    /// Non-fatal codegen warnings (skipped instructions, PDAs degraded to
2889    /// user-provided accounts). Callers should surface these to the user.
2890    pub warnings: Vec<String>,
2891    /// Structured PDA degradations for summary reporting.
2892    pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
2893}
2894
2895impl TypeScriptStackOutput {
2896    pub fn full_file(&self) -> String {
2897        let mut parts = Vec::new();
2898        if !self.imports.is_empty() {
2899            parts.push(self.imports.as_str());
2900        }
2901        if !self.interfaces.is_empty() {
2902            parts.push(self.interfaces.as_str());
2903        }
2904        if !self.stack_definition.is_empty() {
2905            parts.push(self.stack_definition.as_str());
2906        }
2907        parts.join("\n\n")
2908    }
2909}
2910
2911/// Compile a full SerializableStackSpec (multi-entity) into a single TypeScript file.
2912///
2913/// Generates:
2914/// - Interfaces for ALL entities (OreRound, OreTreasury, OreMiner, etc.)
2915/// - A single unified stack definition with nested views per entity
2916/// - View helpers (stateView, listView)
2917pub fn compile_stack_spec(
2918    stack_spec: SerializableStackSpec,
2919    config: Option<TypeScriptStackConfig>,
2920) -> Result<TypeScriptStackOutput, String> {
2921    let config = config.unwrap_or_default();
2922    let stack_name = &stack_spec.stack_name;
2923    let stack_kebab = to_kebab_case(stack_name);
2924
2925    // 1. Compile each entity's interfaces using existing per-entity compiler
2926    let mut all_interfaces = Vec::new();
2927    let mut entity_names = Vec::new();
2928    let mut schema_names: Vec<String> = Vec::new();
2929    let mut emitted_types: HashSet<String> = HashSet::new();
2930
2931    for entity_spec in &stack_spec.entities {
2932        let mut spec = entity_spec.clone();
2933        // Inject stack-level IDL if entity doesn't have its own
2934        if spec.idl.is_none() {
2935            spec.idl = stack_spec.idls.first().cloned();
2936        }
2937        let entity_name = spec.state_name.clone();
2938        entity_names.push(entity_name.clone());
2939
2940        let per_entity_config = TypeScriptConfig {
2941            package_name: config.package_name.clone(),
2942            generate_helpers: false,
2943            interface_prefix: String::new(),
2944            export_const_name: config.export_const_name.clone(),
2945            url: config.url.clone(),
2946        };
2947
2948        // Collect builtin type names before spec is consumed
2949        let builtin_type_names = extract_builtin_resolver_type_names(&spec);
2950        // Clone IDL before spec is moved so we can check which enums were emitted
2951        let idl_for_check = spec.idl.clone();
2952
2953        let output = compile_serializable_spec_with_emitted(
2954            spec,
2955            entity_name,
2956            Some(per_entity_config),
2957            emitted_types.clone(),
2958        )?;
2959
2960        // Track shared types for cross-entity dedup
2961        // Only track enum types that were actually emitted (found in output.interfaces)
2962        let emitted_enum_names =
2963            extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
2964        emitted_types.extend(emitted_enum_names);
2965        emitted_types.extend(builtin_type_names);
2966
2967        // Only take the interfaces part (not the stack_definition — we generate our own)
2968        if !output.interfaces.is_empty() {
2969            all_interfaces.push(output.interfaces);
2970        }
2971
2972        schema_names.extend(output.schema_names);
2973    }
2974
2975    let mut interfaces = all_interfaces.join("\n\n");
2976
2977    // 2. Generate instruction-construction handlers from the stack spec.
2978    // Program errors live once at the stack level (in the IDL snapshots) and
2979    // are scoped per program by the instruction codegen. Entity interface
2980    // names are reserved so defined-type interfaces cannot collide with them.
2981    let mut reserved_type_names: std::collections::HashSet<String> =
2982        std::collections::HashSet::new();
2983    for line in interfaces.lines() {
2984        for prefix in ["export interface ", "export type "] {
2985            if let Some(rest) = line.strip_prefix(prefix) {
2986                let name: String = rest
2987                    .chars()
2988                    .take_while(|c| c.is_alphanumeric() || *c == '_')
2989                    .collect();
2990                if !name.is_empty() {
2991                    reserved_type_names.insert(name);
2992                }
2993            }
2994        }
2995    }
2996    let idl_account_artifacts =
2997        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
2998    for type_name in &idl_account_artifacts.type_names {
2999        reserved_type_names.insert(type_name.clone());
3000    }
3001    if !idl_account_artifacts.code.is_empty() {
3002        if interfaces.is_empty() {
3003            interfaces = idl_account_artifacts.code.clone();
3004        } else {
3005            interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
3006        }
3007    }
3008    schema_names.extend(idl_account_artifacts.schema_names.clone());
3009
3010    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3011        stack_name,
3012        &stack_spec.instructions,
3013        &stack_spec.idls,
3014        &stack_spec.pdas,
3015        &stack_spec.program_ids,
3016        &reserved_type_names,
3017    );
3018    if !instructions_codegen.code.is_empty() {
3019        if interfaces.is_empty() {
3020            interfaces = instructions_codegen.code.clone();
3021        } else {
3022            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3023        }
3024    }
3025
3026    // 3. Generate unified stack definition with all entity views and attached program SDKs.
3027    let stack_definition = generate_stack_definition_multi(
3028        stack_name,
3029        &stack_kebab,
3030        &stack_spec.entities,
3031        &entity_names,
3032        &stack_spec.idls,
3033        &stack_spec.pdas,
3034        &stack_spec.program_ids,
3035        &schema_names,
3036        &idl_account_artifacts.account_type_names,
3037        &instructions_codegen.stack_entries,
3038        &config,
3039    );
3040
3041    // 4. Assemble `@usearete/sdk` imports based on what was actually emitted.
3042    let imports = assemble_sdk_imports(
3043        stack_spec.pdas.values().any(|p| !p.is_empty()),
3044        !idl_account_artifacts.account_type_names.is_empty(),
3045        &instructions_codegen,
3046    );
3047
3048    Ok(TypeScriptStackOutput {
3049        imports,
3050        interfaces,
3051        stack_definition,
3052        warnings: instructions_codegen.warnings,
3053        pda_degradations: instructions_codegen.pda_degradations,
3054    })
3055}
3056
3057/// Assemble the `zod` + `@usearete/sdk` import lines based on which runtime
3058/// helpers the emitted code references.
3059fn assemble_sdk_imports(
3060    has_pdas: bool,
3061    has_account_reads: bool,
3062    instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
3063) -> String {
3064    let mut sdk_named: Vec<String> = Vec::new();
3065    if has_pdas {
3066        for helper in ["pda", "literal", "account", "arg", "bytes"] {
3067            sdk_named.push(helper.to_string());
3068        }
3069    }
3070    if has_account_reads {
3071        sdk_named.push("programAccountRead".to_string());
3072    }
3073    if instructions_codegen.needs_runtime_import {
3074        sdk_named.push("createInstructionHandler".to_string());
3075        sdk_named.push("type ErrorMetadata".to_string());
3076    }
3077    if !instructions_codegen.stack_entries.is_empty() {
3078        sdk_named.push("buildInstruction".to_string());
3079        sdk_named.push("type BuildOptions".to_string());
3080    }
3081    if instructions_codegen.needs_program_runtime_extensions {
3082        sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
3083        sdk_named.push("type ProgramOperationContext".to_string());
3084        sdk_named.push("instructionOperation".to_string());
3085        sdk_named.push("createPreparedInstruction".to_string());
3086    }
3087    if instructions_codegen.needs_amount_input {
3088        sdk_named.push("type AmountInput".to_string());
3089    }
3090    if instructions_codegen.needs_resolve_amount_to_raw {
3091        sdk_named.push("resolveAmountToRaw".to_string());
3092    }
3093    if instructions_codegen.needs_to_raw_amount {
3094        sdk_named.push("toRawAmount".to_string());
3095    }
3096    if sdk_named.is_empty() {
3097        "import { z } from 'zod';".to_string()
3098    } else {
3099        format!(
3100            "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
3101            sdk_named.join(", ")
3102        )
3103    }
3104}
3105
3106/// Compile only the program-SDK surface of a stack spec — account types +
3107/// Zod schemas, instruction handlers, and standalone per-program consts.
3108/// No entities, views, or stack const are emitted, and the spec's `entities`
3109/// may be empty. Used by `a4 sdk create --ts --program-only`.
3110pub fn compile_program_modules(
3111    stack_spec: SerializableStackSpec,
3112    config: Option<TypeScriptStackConfig>,
3113) -> Result<TypeScriptStackOutput, String> {
3114    let _config = config.unwrap_or_default();
3115    let stack_name = &stack_spec.stack_name;
3116
3117    if stack_spec.idls.is_empty() {
3118        return Err(format!(
3119            "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
3120            stack_name
3121        ));
3122    }
3123
3124    let mut reserved_type_names: std::collections::HashSet<String> =
3125        std::collections::HashSet::new();
3126    let idl_account_artifacts =
3127        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
3128    for type_name in &idl_account_artifacts.type_names {
3129        reserved_type_names.insert(type_name.clone());
3130    }
3131
3132    let mut interfaces = idl_account_artifacts.code.clone();
3133
3134    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3135        stack_name,
3136        &stack_spec.instructions,
3137        &stack_spec.idls,
3138        &stack_spec.pdas,
3139        &stack_spec.program_ids,
3140        &reserved_type_names,
3141    );
3142    if !instructions_codegen.code.is_empty() {
3143        if interfaces.is_empty() {
3144            interfaces = instructions_codegen.code.clone();
3145        } else {
3146            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3147        }
3148    }
3149
3150    let unique_schemas: BTreeSet<String> =
3151        idl_account_artifacts.schema_names.iter().cloned().collect();
3152
3153    let stack_definition = generate_program_definitions(
3154        stack_name,
3155        &stack_spec.idls,
3156        &stack_spec.pdas,
3157        &stack_spec.program_ids,
3158        &instructions_codegen.stack_entries,
3159        &unique_schemas,
3160        &idl_account_artifacts.account_type_names,
3161    );
3162
3163    let imports = assemble_sdk_imports(
3164        stack_spec.pdas.values().any(|p| !p.is_empty()),
3165        !idl_account_artifacts.account_type_names.is_empty(),
3166        &instructions_codegen,
3167    );
3168
3169    Ok(TypeScriptStackOutput {
3170        imports,
3171        interfaces,
3172        stack_definition,
3173        warnings: instructions_codegen.warnings,
3174        pda_degradations: instructions_codegen.pda_degradations,
3175    })
3176}
3177
3178/// Write stack-level TypeScript output to a file
3179pub fn write_stack_typescript_to_file(
3180    output: &TypeScriptStackOutput,
3181    path: &std::path::Path,
3182) -> Result<(), std::io::Error> {
3183    std::fs::write(path, output.full_file())
3184}
3185
3186/// Generate a unified stack definition for multiple entities.
3187///
3188/// Produces something like:
3189/// ```typescript
3190/// export const ORE_STACK = {
3191///   name: 'ore',
3192///   url: 'wss://ore.stack.arete.run',
3193///   views: {
3194///     OreRound: {
3195///       state: stateView<OreRound>('OreRound/state'),
3196///       list: listView<OreRound>('OreRound/list'),
3197///       latest: listView<OreRound>('OreRound/latest'),
3198///     },
3199///     OreTreasury: {
3200///       state: stateView<OreTreasury>('OreTreasury/state'),
3201///     },
3202///     OreMiner: {
3203///       state: stateView<OreMiner>('OreMiner/state'),
3204///       list: listView<OreMiner>('OreMiner/list'),
3205///     },
3206///   },
3207/// } as const;
3208/// ```
3209#[allow(clippy::too_many_arguments)]
3210fn generate_stack_definition_multi(
3211    stack_name: &str,
3212    stack_kebab: &str,
3213    entities: &[SerializableStreamSpec],
3214    entity_names: &[String],
3215    idls: &[IdlSnapshot],
3216    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3217    program_ids: &[String],
3218    schema_names: &[String],
3219    account_type_names: &BTreeMap<(String, String), String>,
3220    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3221    config: &TypeScriptStackConfig,
3222) -> String {
3223    let export_name = format!(
3224        "{}_{}",
3225        to_screaming_snake_case(stack_name),
3226        config.export_const_name
3227    );
3228    let core_export_name = format!("{}_CORE", export_name);
3229
3230    let view_helpers = generate_view_helpers_static();
3231
3232    let endpoints_block = match &config.url {
3233        Some(url) => format!(
3234            "  endpoints: {{\n    ws: '{}',\n    http: '{}',\n  }},",
3235            url,
3236            derive_http_url(url)
3237        ),
3238        None => "  endpoints: {\n    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n  },"
3239            .to_string(),
3240    };
3241
3242    // Generate views block for each entity
3243    let mut entity_view_blocks = Vec::new();
3244    for (i, entity_spec) in entities.iter().enumerate() {
3245        let entity_name = &entity_names[i];
3246        let entity_pascal = to_pascal_case(entity_name);
3247
3248        let mut view_entries = Vec::new();
3249
3250        view_entries.push(format!(
3251            "      state: stateView<{entity}>('{entity_name}/state'),",
3252            entity = entity_pascal,
3253            entity_name = entity_name
3254        ));
3255
3256        view_entries.push(format!(
3257            "      list: listView<{entity}>('{entity_name}/list'),",
3258            entity = entity_pascal,
3259            entity_name = entity_name
3260        ));
3261
3262        for view in &entity_spec.views {
3263            if !view.id.ends_with("/state")
3264                && !view.id.ends_with("/list")
3265                && view.id.starts_with(entity_name)
3266            {
3267                let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
3268                view_entries.push(format!(
3269                    "      {}: listView<{entity}>('{}'),",
3270                    view_name,
3271                    view.id,
3272                    entity = entity_pascal
3273                ));
3274            }
3275        }
3276
3277        entity_view_blocks.push(format!(
3278            "    {}: {{\n{}\n    }},",
3279            entity_name,
3280            view_entries.join("\n")
3281        ));
3282    }
3283
3284    let views_body = entity_view_blocks.join("\n");
3285
3286    let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
3287    for name in schema_names {
3288        unique_schemas.insert(name.clone());
3289    }
3290    let schemas_block = if unique_schemas.is_empty() {
3291        String::new()
3292    } else {
3293        let schema_entries: Vec<String> = unique_schemas
3294            .iter()
3295            .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
3296            .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
3297            .collect();
3298        if schema_entries.is_empty() {
3299            String::new()
3300        } else {
3301            format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
3302        }
3303    };
3304    let patch_schema_entries: Vec<String> = entity_names
3305        .iter()
3306        .map(|entity_name| {
3307            let entity_pascal = to_pascal_case(entity_name);
3308            format!("    {}: {}PatchSchema,", entity_pascal, entity_pascal)
3309        })
3310        .collect();
3311    let patch_schemas_block = if patch_schema_entries.is_empty() {
3312        String::new()
3313    } else {
3314        format!(
3315            "\n  patchSchemas: {{\n{}\n  }},",
3316            patch_schema_entries.join("\n")
3317        )
3318    };
3319
3320    let programs_block = generate_programs_block(
3321        idls,
3322        pdas,
3323        program_ids,
3324        instruction_entries,
3325        &unique_schemas,
3326        account_type_names,
3327    );
3328    let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
3329
3330    let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
3331
3332    let stack_export = format!(
3333        r#"export const {core_export_name} = {{
3334  name: '{stack_kebab}',
3335{endpoints_block}
3336  views: {{
3337{views_body}
3338  }},{schemas_section}{patch_schemas_section}{programs_section}{addresses_section}
3339}} as const;"#,
3340        core_export_name = core_export_name,
3341        stack_kebab = stack_kebab,
3342        endpoints_block = endpoints_block,
3343        views_body = views_body,
3344        schemas_section = schemas_block,
3345        patch_schemas_section = patch_schemas_block,
3346        programs_section = programs_block,
3347        addresses_section = addresses_block,
3348    );
3349
3350    format!(
3351        r#"{view_helpers}
3352
3353// ============================================================================
3354// Stack Definition
3355// ============================================================================
3356
3357/** Stack definition for {stack_name} with {entity_count} entities */
3358{stack_export}
3359
3360/** Type alias for the core stack */
3361export type {stack_name}CoreStack = typeof {core_export_name};
3362
3363/** Entity types in this stack */
3364export type {stack_name}Entity = {entity_union};
3365
3366/** Default export for convenience */
3367export default {core_export_name};"#,
3368        view_helpers = view_helpers,
3369        stack_name = stack_name,
3370        entity_count = entities.len(),
3371        core_export_name = core_export_name,
3372        stack_export = stack_export,
3373        entity_union = entity_types.join(" | "),
3374    )
3375}
3376
3377/// Build one program's `{ name, programId, pdas?, accounts?, instructions? }`
3378/// literal body. Sections are indented for nesting inside a stack const
3379/// (`programs: { <key>: { ... } }`); callers emitting top-level program
3380/// consts dedent them.
3381fn generate_single_program_sections(
3382    idl: &IdlSnapshot,
3383    index: usize,
3384    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3385    program_ids: &[String],
3386    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3387    schema_names: &BTreeSet<String>,
3388    account_type_names: &BTreeMap<(String, String), String>,
3389) -> (String, Vec<String>) {
3390    let program_key = to_camel_case(&idl.name);
3391    let multi_program = program_ids.len() > 1
3392        || instruction_entries
3393            .iter()
3394            .any(|entry| entry.program_key.is_some());
3395    let program_id = program_ids
3396        .get(index)
3397        .cloned()
3398        .or_else(|| idl.program_id.clone())
3399        .unwrap_or_default();
3400
3401    let instruction_entries_for_program: Vec<
3402        &crate::typescript_instructions::StackInstructionEntry,
3403    > = instruction_entries
3404        .iter()
3405        .filter(|entry| {
3406            if multi_program {
3407                entry.program_key.as_deref() == Some(program_key.as_str())
3408            } else {
3409                true
3410            }
3411        })
3412        .collect();
3413    let instruction_entry_literals: Vec<String> = instruction_entries_for_program
3414        .iter()
3415        .map(|entry| {
3416            format!(
3417                "        {}: {},",
3418                entry.instruction_name, entry.handler_const
3419            )
3420        })
3421        .collect();
3422
3423    let account_entries: Vec<String> = idl
3424        .accounts
3425        .iter()
3426        .filter_map(|account| {
3427            let type_name = account_type_names
3428                .get(&(program_key.clone(), account.name.clone()))?
3429                .clone();
3430            let schema_name = format!("{}Schema", type_name);
3431            if !schema_names.contains(&schema_name) {
3432                return None;
3433            }
3434            Some((account.name.clone(), type_name, schema_name))
3435        })
3436        .map(|account| {
3437            format!(
3438                "        {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', path: '/programs/{program}/accounts/{account_name}', schema: {schema_name} }}),",
3439                account_name = account.0,
3440                type_name = account.1,
3441                schema_name = account.2,
3442                program = program_key,
3443            )
3444        })
3445        .collect();
3446
3447    let program_pdas = pdas
3448        .get(&idl.name)
3449        .or_else(|| pdas.get(&program_key))
3450        .filter(|program_pdas| !program_pdas.is_empty());
3451
3452    let mut sections: Vec<String> = vec![
3453        format!("      name: '{}',", idl.name),
3454        format!("      programId: '{}',", program_id),
3455    ];
3456
3457    if let Some(program_pdas) = program_pdas {
3458        let pda_entries = generate_program_pda_entries(program_pdas, &program_id, "        ");
3459        if !pda_entries.is_empty() {
3460            sections.push(format!(
3461                "      pdas: {{\n{}\n      }},",
3462                pda_entries.join("\n")
3463            ));
3464            sections.push(format!(
3465                "      addresses: {{\n{}\n      }},",
3466                pda_entries.join("\n")
3467            ));
3468        }
3469    }
3470
3471    if !account_entries.is_empty() {
3472        sections.push(format!(
3473            "      accounts: {{\n{}\n      }},",
3474            account_entries.join("\n")
3475        ));
3476    }
3477
3478    if !instruction_entry_literals.is_empty() {
3479        sections.push(format!(
3480            "      rawInstructions: {{\n{}\n      }},",
3481            instruction_entry_literals.join("\n")
3482        ));
3483        if let Some(semantic_block) =
3484            generate_program_semantic_instructions_block(&instruction_entries_for_program, "      ")
3485        {
3486            sections.push(semantic_block);
3487        }
3488    }
3489
3490    (program_key, sections)
3491}
3492
3493fn generate_programs_block(
3494    idls: &[IdlSnapshot],
3495    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3496    program_ids: &[String],
3497    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3498    schema_names: &BTreeSet<String>,
3499    account_type_names: &BTreeMap<(String, String), String>,
3500) -> String {
3501    if idls.is_empty() {
3502        return String::new();
3503    }
3504
3505    let mut program_blocks = Vec::new();
3506
3507    for (index, idl) in idls.iter().enumerate() {
3508        let (program_key, sections) = generate_single_program_sections(
3509            idl,
3510            index,
3511            pdas,
3512            program_ids,
3513            instruction_entries,
3514            schema_names,
3515            account_type_names,
3516        );
3517
3518        program_blocks.push(format!(
3519            "    {}: {{\n{}\n    }},",
3520            program_key,
3521            sections.join("\n")
3522        ));
3523    }
3524
3525    if program_blocks.is_empty() {
3526        return String::new();
3527    }
3528
3529    format!("\n  programs: {{\n{}\n  }},", program_blocks.join("\n"))
3530}
3531
3532/// Strip up to `spaces` leading spaces from every line.
3533fn dedent_lines(text: &str, spaces: usize) -> String {
3534    text.lines()
3535        .map(|line| {
3536            let strip = line
3537                .char_indices()
3538                .take_while(|(i, c)| *i < spaces && *c == ' ')
3539                .count();
3540            &line[strip..]
3541        })
3542        .collect::<Vec<_>>()
3543        .join("\n")
3544}
3545
3546/// Emit standalone per-program consts plus a combined `<STACK>_PROGRAMS` map:
3547///
3548/// ```typescript
3549/// export const SQUADS_MULTISIG_PROGRAM = { name, programId, pdas, accounts, instructions } as const;
3550/// export const SQUADS_V4_PROGRAMS = { squadsMultisigProgram: SQUADS_MULTISIG_PROGRAM } as const;
3551/// ```
3552///
3553/// Each const structurally satisfies the runtime's `ProgramSdkDefinition`, so
3554/// the map can be dropped straight into `createSession({ programs: ... })`.
3555fn generate_program_definitions(
3556    stack_name: &str,
3557    idls: &[IdlSnapshot],
3558    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3559    program_ids: &[String],
3560    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
3561    schema_names: &BTreeSet<String>,
3562    account_type_names: &BTreeMap<(String, String), String>,
3563) -> String {
3564    let mut program_consts = Vec::new();
3565    let mut map_entries = Vec::new();
3566
3567    for (index, idl) in idls.iter().enumerate() {
3568        let (program_key, sections) = generate_single_program_sections(
3569            idl,
3570            index,
3571            pdas,
3572            program_ids,
3573            instruction_entries,
3574            schema_names,
3575            account_type_names,
3576        );
3577        let const_name = to_screaming_snake_case(&idl.name);
3578        let body = dedent_lines(&sections.join("\n"), 4);
3579        program_consts.push(format!(
3580            "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
3581            name = idl.name,
3582            const_name = const_name,
3583            body = body,
3584        ));
3585        map_entries.push(format!("  {}: {},", program_key, const_name));
3586    }
3587
3588    let map_name = format!("{}_PROGRAMS", to_screaming_snake_case(stack_name));
3589    let type_name = format!("{}Programs", to_pascal_case(stack_name));
3590
3591    format!(
3592        r#"// ============================================================================
3593// Program Definitions
3594// ============================================================================
3595
3596{program_consts}
3597
3598    /** All programs from the {stack_name} stack, ready for createSession({{ programs: ... }}) */
3599export const {map_name} = {{
3600{map_entries}
3601}} as const;
3602
3603export type {type_name} = typeof {map_name};
3604
3605export default {map_name};"#,
3606        program_consts = program_consts.join("\n\n"),
3607        stack_name = stack_name,
3608        map_name = map_name,
3609        map_entries = map_entries.join("\n"),
3610        type_name = type_name,
3611    )
3612}
3613
3614fn generate_program_pda_entries(
3615    program_pdas: &BTreeMap<String, PdaDefinition>,
3616    default_program_id: &str,
3617    indent: &str,
3618) -> Vec<String> {
3619    if program_pdas.is_empty() {
3620        return Vec::new();
3621    }
3622
3623    program_pdas
3624        .iter()
3625        .map(|(pda_name, pda_def)| {
3626            let seeds_str = pda_def
3627                .seeds
3628                .iter()
3629                .map(|seed| match seed {
3630                    PdaSeedDef::Literal { value } => format!("literal('{}')", value),
3631                    PdaSeedDef::AccountRef { account_name } => {
3632                        format!("account('{}')", account_name)
3633                    }
3634                    PdaSeedDef::ArgRef { arg_name, arg_type } => {
3635                        if let Some(t) = arg_type {
3636                            format!("arg('{}', '{}')", arg_name, t)
3637                        } else {
3638                            format!("arg('{}')", arg_name)
3639                        }
3640                    }
3641                    PdaSeedDef::Bytes { value } => {
3642                        let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
3643                        format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
3644                    }
3645                })
3646                .collect::<Vec<_>>()
3647                .join(", ");
3648
3649            let pid = pda_def.program_id.as_deref().unwrap_or(default_program_id);
3650            format!("{}{}: pda('{}', {}),", indent, pda_name, pid, seeds_str)
3651        })
3652        .collect()
3653}
3654
3655fn generate_stack_addresses_block(
3656    idls: &[IdlSnapshot],
3657    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3658    program_ids: &[String],
3659) -> String {
3660    if idls.is_empty() {
3661        return String::new();
3662    }
3663
3664    if idls.len() == 1 {
3665        let idl = &idls[0];
3666        let program_id = program_ids
3667            .first()
3668            .cloned()
3669            .or_else(|| idl.program_id.clone())
3670            .unwrap_or_default();
3671        let Some(program_pdas) = pdas
3672            .get(&idl.name)
3673            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
3674        else {
3675            return String::new();
3676        };
3677        let entries = generate_program_pda_entries(program_pdas, &program_id, "    ");
3678        if entries.is_empty() {
3679            return String::new();
3680        }
3681        return format!("\n  addresses: {{\n{}\n  }},", entries.join("\n"));
3682    }
3683
3684    let mut blocks = Vec::new();
3685    for (index, idl) in idls.iter().enumerate() {
3686        let program_id = program_ids
3687            .get(index)
3688            .cloned()
3689            .or_else(|| idl.program_id.clone())
3690            .unwrap_or_default();
3691        let Some(program_pdas) = pdas
3692            .get(&idl.name)
3693            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
3694        else {
3695            continue;
3696        };
3697        let entries = generate_program_pda_entries(program_pdas, &program_id, "      ");
3698        if entries.is_empty() {
3699            continue;
3700        }
3701        blocks.push(format!(
3702            "    {}: {{\n{}\n    }},",
3703            to_camel_case(&idl.name),
3704            entries.join("\n")
3705        ));
3706    }
3707
3708    if blocks.is_empty() {
3709        String::new()
3710    } else {
3711        format!("\n  addresses: {{\n{}\n  }},", blocks.join("\n"))
3712    }
3713}
3714
3715fn is_valid_ts_identifier(name: &str) -> bool {
3716    name.chars()
3717        .next()
3718        .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
3719        .unwrap_or(false)
3720        && name
3721            .chars()
3722            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
3723}
3724
3725fn escape_ts_single_quotes(value: &str) -> String {
3726    value
3727        .replace('\\', "\\\\")
3728        .replace('\'', "\\'")
3729        .replace(['\n', '\r'], " ")
3730}
3731
3732fn render_ts_property_name_literal(name: &str) -> String {
3733    if is_valid_ts_identifier(name) {
3734        name.to_string()
3735    } else {
3736        format!("'{}'", escape_ts_single_quotes(name))
3737    }
3738}
3739
3740fn render_program_semantic_instruction_entry(
3741    entry: &crate::typescript_instructions::StackInstructionEntry,
3742    indent: &str,
3743) -> Option<String> {
3744    let semantic_params_type = entry.semantic_params_type.as_ref()?;
3745    entry.runtime_program_key.as_ref()?;
3746
3747    if entry.semantic_amount_args.is_empty() {
3748        return Some(format!(
3749            "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{indent}  const instruction = buildInstruction({handler_const}, params as unknown as Record<string, unknown>);\n{indent}  return createPreparedInstruction({{\n{indent}    name: '{instruction_name}',\n{indent}    instruction,\n{indent}    artifacts: {{ instruction }},\n{indent}    errors: {handler_const}.errors,\n{indent}  }});\n{indent}}}),",
3750            indent = indent,
3751            instruction_name = entry.instruction_name,
3752            semantic_params_type = semantic_params_type,
3753            handler_const = entry.handler_const,
3754        ));
3755    }
3756
3757    let raw_params_setup = if entry.semantic_extra_params.is_empty() {
3758        format!(
3759            "{indent}    const {{ build, ...rawParams }} = params;",
3760            indent = indent
3761        )
3762    } else {
3763        format!(
3764            "{indent}    const {{ build, {extras}, ...rawParams }} = params;",
3765            indent = indent,
3766            extras = entry.semantic_extra_params.join(", "),
3767        )
3768    };
3769    let resolution_lines: Vec<String> = entry
3770        .semantic_amount_args
3771        .iter()
3772        .map(|amount_arg| {
3773            format!(
3774                "{indent}    const {binding_name} = {raw_expression};",
3775                indent = indent,
3776                binding_name = amount_arg.binding_name,
3777                raw_expression = amount_arg.raw_expression,
3778            )
3779        })
3780        .collect();
3781    let raw_assignments: Vec<String> = entry
3782        .semantic_amount_args
3783        .iter()
3784        .map(|amount_arg| {
3785            format!(
3786                "{indent}      {}: {},",
3787                render_ts_property_name_literal(&amount_arg.arg_name),
3788                amount_arg.binding_name,
3789                indent = indent,
3790            )
3791        })
3792        .collect();
3793
3794    Some(format!(
3795        "{indent}{instruction_name}: instructionOperation(async (params: {semantic_params_type}) => {{\n{raw_params_setup}\n{resolutions}\n{indent}    const instruction = buildInstruction({handler_const}, {{\n{indent}      ...rawParams,\n{assignments}\n{indent}    }} as unknown as Record<string, unknown>, build);\n{indent}    return createPreparedInstruction({{\n{indent}      name: '{instruction_name}',\n{indent}      instruction,\n{indent}      artifacts: {{ instruction }},\n{indent}      errors: {handler_const}.errors,\n{indent}    }});\n{indent}  }}),",
3796        indent = indent,
3797        instruction_name = entry.instruction_name,
3798        semantic_params_type = semantic_params_type,
3799        raw_params_setup = raw_params_setup,
3800        resolutions = resolution_lines.join("\n"),
3801        handler_const = entry.handler_const,
3802        assignments = raw_assignments.join("\n"),
3803    ))
3804}
3805
3806fn generate_program_semantic_instructions_block(
3807    instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
3808    indent: &str,
3809) -> Option<String> {
3810    let entry_indent = format!("{}      ", indent);
3811    let entries: Vec<String> = instruction_entries
3812        .iter()
3813        .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
3814        .collect();
3815    if entries.is_empty() {
3816        return None;
3817    }
3818
3819    Some(format!(
3820        "{indent}[PROGRAM_OPERATION_EXTENSIONS]: {{\n{indent}  createOperations(context: ProgramOperationContext) {{\n{indent}    return {{\n{indent}      instructions: {{\n{entries}\n{indent}      }},\n{indent}    }};\n{indent}  }},\n{indent}}},",
3821        indent = indent,
3822        entries = entries.join("\n"),
3823    ))
3824}
3825
3826fn derive_http_url(ws_url: &str) -> String {
3827    if ws_url.starts_with("wss://") {
3828        ws_url.replacen("wss://", "https://", 1)
3829    } else if ws_url.starts_with("ws://") {
3830        ws_url.replacen("ws://", "http://", 1)
3831    } else {
3832        ws_url.to_string()
3833    }
3834}
3835
3836fn generate_view_helpers_static() -> String {
3837    r#"// ============================================================================
3838// View Definition Types (framework-agnostic)
3839// ============================================================================
3840
3841/** View definition with embedded entity type */
3842export interface ViewDef<T, TMode extends 'state' | 'list'> {
3843  readonly mode: TMode;
3844  readonly view: string;
3845  /** Phantom field for type inference - not present at runtime */
3846  readonly _entity?: T;
3847}
3848
3849/** Helper to create typed state view definitions (keyed lookups) */
3850function stateView<T>(view: string): ViewDef<T, 'state'> {
3851  return { mode: 'state', view } as const;
3852}
3853
3854/** Helper to create typed list view definitions (collections) */
3855function listView<T>(view: string): ViewDef<T, 'list'> {
3856  return { mode: 'list', view } as const;
3857}"#
3858    .to_string()
3859}
3860
3861/// Convert PascalCase to SCREAMING_SNAKE_CASE (e.g., "OreStream" -> "ORE_STREAM")
3862pub(crate) fn to_screaming_snake_case(s: &str) -> String {
3863    let mut result = String::new();
3864    for (i, ch) in s.chars().enumerate() {
3865        if ch.is_uppercase() && i > 0 {
3866            result.push('_');
3867        }
3868        result.push(ch.to_uppercase().next().unwrap());
3869    }
3870    result
3871}
3872
3873#[cfg(test)]
3874mod tests {
3875    use super::*;
3876
3877    #[test]
3878    fn test_case_conversions() {
3879        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
3880        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
3881    }
3882
3883    #[test]
3884    fn test_normalize_for_comparison() {
3885        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
3886        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
3887        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
3888        assert_eq!(
3889            normalize_for_comparison("admin_set_creator"),
3890            "adminsetcreator"
3891        );
3892        assert_eq!(
3893            normalize_for_comparison("AdminSetCreator"),
3894            "adminsetcreator"
3895        );
3896    }
3897
3898    #[test]
3899    fn test_value_to_typescript_type() {
3900        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
3901        assert_eq!(
3902            value_to_typescript_type(&serde_json::json!("hello")),
3903            "string"
3904        );
3905        assert_eq!(
3906            value_to_typescript_type(&serde_json::json!(true)),
3907            "boolean"
3908        );
3909        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
3910    }
3911
3912    #[test]
3913    fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
3914        let spec = SerializableStreamSpec {
3915            ast_version: CURRENT_AST_VERSION.to_string(),
3916            state_name: "TokenPosition".to_string(),
3917            program_id: None,
3918            idl: None,
3919            identity: IdentitySpec {
3920                primary_keys: vec!["id.address".to_string()],
3921                lookup_indexes: vec![],
3922            },
3923            handlers: vec![],
3924            sections: vec![
3925                EntitySection {
3926                    name: "root".to_string(),
3927                    fields: vec![FieldTypeInfo::new(
3928                        "total_deposit".to_string(),
3929                        "u64".to_string(),
3930                    )],
3931                    is_nested_struct: false,
3932                    parent_field: None,
3933                },
3934                EntitySection {
3935                    name: "metrics".to_string(),
3936                    fields: vec![FieldTypeInfo::new(
3937                        "last_updated_at".to_string(),
3938                        "i64".to_string(),
3939                    )],
3940                    is_nested_struct: false,
3941                    parent_field: None,
3942                },
3943            ],
3944            field_mappings: BTreeMap::new(),
3945            resolver_hooks: vec![],
3946            resolver_specs: vec![],
3947            instruction_hooks: vec![],
3948            computed_fields: vec![],
3949            computed_field_specs: vec![],
3950            content_hash: None,
3951            views: vec![],
3952        };
3953
3954        let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
3955            .expect("should compile");
3956        let file = output.full_file();
3957
3958        assert!(
3959            file.contains("export interface TokenPosition {"),
3960            "missing main interface:\n{}",
3961            file
3962        );
3963        assert!(
3964            file.contains("totalDeposit: bigint;"),
3965            "missing root canonical field:\n{}",
3966            file
3967        );
3968        assert!(
3969            file.contains("metrics: TokenPositionMetrics;"),
3970            "missing nested canonical section field:\n{}",
3971            file
3972        );
3973        assert!(
3974            file.contains("export interface TokenPositionMetrics {"),
3975            "missing nested interface:\n{}",
3976            file
3977        );
3978        assert!(
3979            file.contains("lastUpdatedAt: bigint;"),
3980            "missing nested canonical field:\n{}",
3981            file
3982        );
3983
3984        let bigint_schema = bigint_zod();
3985        assert!(
3986            file.contains("export const TokenPositionSchema = z.object({"),
3987            "missing main schema:\n{}",
3988            file
3989        );
3990        assert!(
3991            file.contains(&format!("total_deposit: {},", bigint_schema)),
3992            "missing raw root schema field:\n{}",
3993            file
3994        );
3995        assert!(
3996            file.contains("metrics: TokenPositionMetricsSchema,"),
3997            "missing nested schema ref:\n{}",
3998            file
3999        );
4000        assert!(
4001            file.contains("totalDeposit: value.total_deposit,"),
4002            "missing root transform:\n{}",
4003            file
4004        );
4005        assert!(
4006            file.contains("metrics: value.metrics,"),
4007            "missing nested transform:\n{}",
4008            file
4009        );
4010
4011        assert!(
4012            file.contains("export const TokenPositionMetricsSchema = z.object({"),
4013            "missing nested schema:\n{}",
4014            file
4015        );
4016        assert!(
4017            file.contains(&format!("last_updated_at: {},", bigint_schema)),
4018            "missing raw nested schema field:\n{}",
4019            file
4020        );
4021        assert!(
4022            file.contains("lastUpdatedAt: value.last_updated_at,"),
4023            "missing nested transform:\n{}",
4024            file
4025        );
4026
4027        assert!(
4028            file.contains("export const TokenPositionPatchSchema = z.object({"),
4029            "missing patch schema:\n{}",
4030            file
4031        );
4032        assert!(
4033            file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
4034            "missing sparse patch field:\n{}",
4035            file
4036        );
4037        assert!(
4038            file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
4039            "missing nested patch schema ref:\n{}",
4040            file
4041        );
4042        assert!(
4043            file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
4044            "missing sparse patch transform:\n{}",
4045            file
4046        );
4047        assert!(
4048            file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
4049            "missing nested patch schema:\n{}",
4050            file
4051        );
4052        assert!(
4053            file.contains("patchSchemas: {\n    TokenPosition: TokenPositionPatchSchema,"),
4054            "missing stack patch schema map:\n{}",
4055            file
4056        );
4057    }
4058
4059    #[test]
4060    fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
4061        let spec = SerializableStreamSpec {
4062            ast_version: CURRENT_AST_VERSION.to_string(),
4063            state_name: "TokenHolder".to_string(),
4064            program_id: None,
4065            idl: None,
4066            identity: IdentitySpec {
4067                primary_keys: vec!["id.address".to_string()],
4068                lookup_indexes: vec![],
4069            },
4070            handlers: vec![],
4071            sections: vec![EntitySection {
4072                name: "root".to_string(),
4073                fields: vec![FieldTypeInfo {
4074                    field_name: "base_token_metadata".to_string(),
4075                    raw_name: Some("base_token_metadata".to_string()),
4076                    canonical_name: Some("baseTokenMetadata".to_string()),
4077                    rust_type_name: "Option<TokenMetadata>".to_string(),
4078                    base_type: BaseType::Object,
4079                    integer_kind: None,
4080                    is_optional: true,
4081                    is_array: false,
4082                    inner_type: Some("TokenMetadata".to_string()),
4083                    source_path: None,
4084                    resolved_type: None,
4085                    emit: true,
4086                }],
4087                is_nested_struct: false,
4088                parent_field: None,
4089            }],
4090            field_mappings: BTreeMap::new(),
4091            resolver_hooks: vec![],
4092            resolver_specs: vec![],
4093            instruction_hooks: vec![],
4094            computed_fields: vec![],
4095            computed_field_specs: vec![],
4096            content_hash: None,
4097            views: vec![],
4098        };
4099
4100        let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
4101            .expect("should compile");
4102        let file = output.full_file();
4103
4104        assert!(
4105            file.contains("export interface TokenMetadata {"),
4106            "missing builtin interface:\n{}",
4107            file
4108        );
4109        assert!(
4110            file.contains("logoUri?: string | null;"),
4111            "missing canonical builtin field:\n{}",
4112            file
4113        );
4114        assert!(
4115            file.contains("export const TokenMetadataSchema = z.object({"),
4116            "missing builtin schema:\n{}",
4117            file
4118        );
4119        assert!(
4120            file.contains("logo_uri: z.string().nullable().optional(),"),
4121            "missing raw builtin input field:\n{}",
4122            file
4123        );
4124        assert!(
4125            file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
4126            "missing canonical builtin transform:\n{}",
4127            file
4128        );
4129        assert!(
4130            file.contains("export const TokenMetadataPatchSchema = z.object({"),
4131            "missing builtin patch schema:\n{}",
4132            file
4133        );
4134        assert!(
4135            file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
4136            "missing patch schema usage for builtin field:\n{}",
4137            file
4138        );
4139    }
4140
4141    #[test]
4142    fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
4143        let spec = SerializableStreamSpec {
4144            ast_version: CURRENT_AST_VERSION.to_string(),
4145            state_name: "OreRound".to_string(),
4146            program_id: None,
4147            idl: None,
4148            identity: IdentitySpec {
4149                primary_keys: vec!["id.round_id".to_string()],
4150                lookup_indexes: vec![],
4151            },
4152            handlers: vec![],
4153            sections: vec![EntitySection {
4154                name: "results".to_string(),
4155                fields: vec![FieldTypeInfo {
4156                    field_name: "results.expires_at_slot_hash".to_string(),
4157                    raw_name: Some("results.expires_at_slot_hash".to_string()),
4158                    canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
4159                    rust_type_name: "Option<String>".to_string(),
4160                    base_type: BaseType::String,
4161                    integer_kind: None,
4162                    is_optional: true,
4163                    is_array: false,
4164                    inner_type: Some("String".to_string()),
4165                    source_path: None,
4166                    resolved_type: None,
4167                    emit: true,
4168                }],
4169                is_nested_struct: false,
4170                parent_field: None,
4171            }],
4172            field_mappings: BTreeMap::new(),
4173            resolver_hooks: vec![],
4174            resolver_specs: vec![],
4175            instruction_hooks: vec![],
4176            computed_fields: vec![],
4177            computed_field_specs: vec![],
4178            content_hash: None,
4179            views: vec![],
4180        };
4181
4182        let output =
4183            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
4184        let file = output.full_file();
4185
4186        assert!(
4187            file.contains("export interface OreRoundResults {"),
4188            "missing section interface:\n{}",
4189            file
4190        );
4191        assert!(
4192            file.contains("expiresAtSlotHash: string | null;"),
4193            "missing localized canonical field:\n{}",
4194            file
4195        );
4196        assert!(
4197            file.contains("expires_at_slot_hash: z.string().nullable(),"),
4198            "missing localized raw schema field:\n{}",
4199            file
4200        );
4201        assert!(
4202            file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
4203            "missing localized transform:\n{}",
4204            file
4205        );
4206        assert!(
4207            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
4208            "missing localized patch schema field:\n{}",
4209            file
4210        );
4211        assert!(
4212            file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
4213            "missing localized sparse transform:\n{}",
4214            file
4215        );
4216    }
4217
4218    #[test]
4219    fn test_derived_view_codegen() {
4220        let spec = SerializableStreamSpec {
4221            ast_version: CURRENT_AST_VERSION.to_string(),
4222            state_name: "OreRound".to_string(),
4223            program_id: None,
4224            idl: None,
4225            identity: IdentitySpec {
4226                primary_keys: vec!["id".to_string()],
4227                lookup_indexes: vec![],
4228            },
4229            handlers: vec![],
4230            sections: vec![],
4231            field_mappings: BTreeMap::new(),
4232            resolver_hooks: vec![],
4233            resolver_specs: vec![],
4234            instruction_hooks: vec![],
4235            computed_fields: vec![],
4236            computed_field_specs: vec![],
4237            content_hash: None,
4238            views: vec![
4239                ViewDef {
4240                    id: "OreRound/latest".to_string(),
4241                    source: ViewSource::Entity {
4242                        name: "OreRound".to_string(),
4243                    },
4244                    pipeline: vec![ViewTransform::Last],
4245                    output: ViewOutput::Single,
4246                },
4247                ViewDef {
4248                    id: "OreRound/top10".to_string(),
4249                    source: ViewSource::Entity {
4250                        name: "OreRound".to_string(),
4251                    },
4252                    pipeline: vec![ViewTransform::Take { count: 10 }],
4253                    output: ViewOutput::Collection,
4254                },
4255            ],
4256        };
4257
4258        let output =
4259            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
4260
4261        let stack_def = &output.stack_definition;
4262
4263        assert!(
4264            stack_def.contains("listView<OreRound>('OreRound/latest')"),
4265            "Expected 'latest' derived view using listView, got:\n{}",
4266            stack_def
4267        );
4268        assert!(
4269            stack_def.contains("listView<OreRound>('OreRound/top10')"),
4270            "Expected 'top10' derived view using listView, got:\n{}",
4271            stack_def
4272        );
4273        assert!(
4274            stack_def.contains("latest:"),
4275            "Expected 'latest' key, got:\n{}",
4276            stack_def
4277        );
4278        assert!(
4279            stack_def.contains("top10:"),
4280            "Expected 'top10' key, got:\n{}",
4281            stack_def
4282        );
4283        assert!(
4284            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
4285            "Expected listView helper function, got:\n{}",
4286            stack_def
4287        );
4288    }
4289
4290    #[test]
4291    fn test_account_type_collision_uses_account_suffix() {
4292        let plan_field = FieldTypeInfo {
4293            field_name: "plan".to_string(),
4294            raw_name: Some("plan".to_string()),
4295            canonical_name: Some("plan".to_string()),
4296            rust_type_name: "Option<serde_json::Value>".to_string(),
4297            base_type: BaseType::Object,
4298            integer_kind: None,
4299            is_optional: false,
4300            is_array: false,
4301            inner_type: Some("Value".to_string()),
4302            source_path: None,
4303            resolved_type: Some(ResolvedStructType {
4304                type_name: "plan".to_string(),
4305                fields: vec![],
4306                is_instruction: false,
4307                is_account: true,
4308                is_event: false,
4309                is_enum: false,
4310                enum_variants: vec![],
4311            }),
4312            emit: true,
4313        };
4314
4315        let spec = SerializableStreamSpec {
4316            ast_version: CURRENT_AST_VERSION.to_string(),
4317            state_name: "Plan".to_string(),
4318            program_id: None,
4319            idl: None,
4320            identity: IdentitySpec {
4321                primary_keys: vec!["id.address".to_string()],
4322                lookup_indexes: vec![],
4323            },
4324            handlers: vec![],
4325            sections: vec![
4326                EntitySection {
4327                    name: "id".to_string(),
4328                    fields: vec![FieldTypeInfo::new(
4329                        "address".to_string(),
4330                        "String".to_string(),
4331                    )],
4332                    is_nested_struct: false,
4333                    parent_field: None,
4334                },
4335                EntitySection {
4336                    name: "plan".to_string(),
4337                    fields: vec![plan_field],
4338                    is_nested_struct: false,
4339                    parent_field: None,
4340                },
4341            ],
4342            field_mappings: BTreeMap::new(),
4343            resolver_hooks: vec![],
4344            instruction_hooks: vec![],
4345            resolver_specs: vec![],
4346            computed_fields: vec![],
4347            computed_field_specs: vec![],
4348            content_hash: None,
4349            views: vec![],
4350        };
4351
4352        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
4353            .expect("typescript sdk generation should succeed");
4354
4355        assert!(
4356            output.interfaces.contains("export interface PlanPlan {"),
4357            "expected PlanPlan section interface, got:\n{}",
4358            output.interfaces
4359        );
4360        assert!(
4361            output.interfaces.contains("plan: PlanAccount;"),
4362            "expected PlanAccount field reference, got:\n{}",
4363            output.interfaces
4364        );
4365        assert!(
4366            output.interfaces.contains("export interface PlanAccount {"),
4367            "expected PlanAccount interface, got:\n{}",
4368            output.interfaces
4369        );
4370    }
4371
4372    #[test]
4373    fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
4374        let shared_idl = serde_json::json!({
4375            "name": "subscriptions",
4376            "version": "0.1.0",
4377            "accounts": [],
4378            "instructions": [],
4379            "types": [
4380                {
4381                    "name": "planStatus",
4382                    "type": {
4383                        "kind": "enum",
4384                        "variants": [{ "name": "sunset" }, { "name": "active" }]
4385                    }
4386                }
4387            ],
4388            "events": [],
4389            "errors": [],
4390            "discriminant_size": 8
4391        });
4392
4393        let idl_snapshot: IdlSnapshot =
4394            serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
4395
4396        let make_entity = |name: &str| SerializableStreamSpec {
4397            ast_version: CURRENT_AST_VERSION.to_string(),
4398            state_name: name.to_string(),
4399            program_id: None,
4400            idl: None,
4401            identity: IdentitySpec {
4402                primary_keys: vec!["id.address".to_string()],
4403                lookup_indexes: vec![],
4404            },
4405            handlers: vec![],
4406            sections: vec![EntitySection {
4407                name: "id".to_string(),
4408                fields: vec![FieldTypeInfo::new(
4409                    "address".to_string(),
4410                    "String".to_string(),
4411                )],
4412                is_nested_struct: false,
4413                parent_field: None,
4414            }],
4415            field_mappings: BTreeMap::new(),
4416            resolver_hooks: vec![],
4417            instruction_hooks: vec![],
4418            resolver_specs: vec![],
4419            computed_fields: vec![],
4420            computed_field_specs: vec![],
4421            content_hash: None,
4422            views: vec![],
4423        };
4424
4425        let stack_spec = SerializableStackSpec {
4426            ast_version: CURRENT_AST_VERSION.to_string(),
4427            stack_name: "Subscriptions".to_string(),
4428            program_ids: vec![],
4429            idls: vec![idl_snapshot],
4430            entities: vec![make_entity("Plan"), make_entity("Subscription")],
4431            pdas: BTreeMap::new(),
4432            instructions: vec![],
4433            content_hash: None,
4434        };
4435
4436        let output =
4437            compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
4438        let file = output.full_file();
4439        let count = output
4440            .interfaces
4441            .matches("export type PlanStatus =")
4442            .count();
4443
4444        assert_eq!(
4445            count, 1,
4446            "expected shared enum type to be emitted once, got:\n{}",
4447            output.interfaces
4448        );
4449        assert!(
4450            file.contains("_STACK_CORE = {"),
4451            "core export missing:\n{}",
4452            file
4453        );
4454        assert!(
4455            !file.contains("extendStack"),
4456            "no extension wiring expected:\n{}",
4457            file
4458        );
4459    }
4460
4461    #[test]
4462    fn golden_ore_stack_json_compiles_program_modules_without_entities() {
4463        let path = concat!(
4464            env!("CARGO_MANIFEST_DIR"),
4465            "/../stacks/ore/.arete/OreStream.stack.json"
4466        );
4467        let json = match std::fs::read_to_string(path) {
4468            Ok(c) => c,
4469            // Stack JSON is generated by the macro build; skip if not present.
4470            Err(_) => return,
4471        };
4472        let mut spec: SerializableStackSpec =
4473            serde_json::from_str(&json).expect("ore stack json should deserialize");
4474
4475        // Program-only emission must not depend on entities at all.
4476        spec.entities.clear();
4477
4478        let output =
4479            compile_program_modules(spec, None).expect("program-module compilation should succeed");
4480        let file = output.full_file();
4481
4482        // Standalone per-program consts plus the combined map.
4483        assert!(
4484            file.contains("export const ORE = {"),
4485            "ore const missing:\n{}",
4486            file
4487        );
4488        assert!(
4489            file.contains("export const ENTROPY = {"),
4490            "entropy const missing"
4491        );
4492        assert!(
4493            file.contains("export const ORE_STREAM_PROGRAMS = {"),
4494            "combined program map missing"
4495        );
4496        assert!(file.contains("  ore: ORE,"));
4497        assert!(file.contains("  entropy: ENTROPY,"));
4498        assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
4499        assert!(file.contains("ready for createSession({ programs: ... })"));
4500
4501        // Program bodies keep the full SDK surface...
4502        assert!(file.contains("createInstructionHandler"));
4503        assert!(file.contains("pdas: {"));
4504        assert!(file.contains("addresses: {"));
4505        assert!(file.contains("instructions: {"));
4506        assert!(file.contains("createPreparedInstruction({"));
4507        assert!(file.contains("buildInstruction("));
4508
4509        // ...but nothing stack- or view-shaped is emitted.
4510        assert!(!file.contains("stateView"), "no view helpers expected");
4511        assert!(!file.contains("listView"), "no view helpers expected");
4512        assert!(!file.contains("views:"), "no views block expected");
4513        assert!(!file.contains("endpoints:"), "no endpoints block expected");
4514        assert!(
4515            !file.contains("extendStack"),
4516            "no extension wiring expected"
4517        );
4518    }
4519
4520    #[test]
4521    fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
4522        let stack_spec = SerializableStackSpec {
4523            ast_version: CURRENT_AST_VERSION.to_string(),
4524            stack_name: "DemoStream".to_string(),
4525            program_ids: vec!["Prog111".to_string()],
4526            idls: vec![IdlSnapshot {
4527                name: "demo".to_string(),
4528                program_id: Some("Prog111".to_string()),
4529                version: "0.1.0".to_string(),
4530                accounts: vec![],
4531                instructions: vec![IdlInstructionSnapshot {
4532                    name: "deposit".to_string(),
4533                    discriminator: vec![9],
4534                    discriminant: None,
4535                    docs: vec![],
4536                    accounts: vec![],
4537                    args: vec![
4538                        IdlFieldSnapshot {
4539                            name: "amount".to_string(),
4540                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
4541                            amount_hint: None,
4542                        },
4543                        IdlFieldSnapshot {
4544                            name: "mint".to_string(),
4545                            type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
4546                            amount_hint: None,
4547                        },
4548                    ],
4549                }],
4550                types: vec![],
4551                events: vec![],
4552                errors: vec![],
4553                discriminant_size: 1,
4554            }],
4555            entities: vec![],
4556            pdas: BTreeMap::new(),
4557            instructions: vec![InstructionDef {
4558                name: "deposit".to_string(),
4559                discriminator: vec![9],
4560                discriminator_size: 1,
4561                accounts: vec![],
4562                args: vec![
4563                    InstructionArgDef {
4564                        name: "amount".to_string(),
4565                        arg_type: "u64".to_string(),
4566                        docs: vec![],
4567                        amount_hint: Some(InstructionAmountHint {
4568                            decimals_source: AmountDecimalsSource::ArgMint {
4569                                arg_name: "mint".to_string(),
4570                            },
4571                        }),
4572                    },
4573                    InstructionArgDef {
4574                        name: "mint".to_string(),
4575                        arg_type: "solana_pubkey::Pubkey".to_string(),
4576                        docs: vec![],
4577                        amount_hint: None,
4578                    },
4579                ],
4580                errors: vec![],
4581                program_id: Some("Prog111".to_string()),
4582                docs: vec![],
4583            }],
4584            content_hash: None,
4585        };
4586
4587        let output = compile_program_modules(stack_spec, None)
4588            .expect("program-module compilation should succeed");
4589        let file = output.full_file();
4590
4591        assert!(
4592            file.contains("type AmountInput"),
4593            "amount import missing:\n{}",
4594            file
4595        );
4596        assert!(
4597            file.contains("PROGRAM_OPERATION_EXTENSIONS"),
4598            "runtime extension import missing"
4599        );
4600        assert!(
4601            file.contains("resolveAmountToRaw"),
4602            "amount resolver import missing"
4603        );
4604        assert!(file.contains("export interface DepositSemanticParams"));
4605        assert!(file.contains("build?: BuildOptions;"));
4606        assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
4607        assert!(file
4608            .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
4609        assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
4610        assert!(file.contains("resolveAmountToRaw(context.chain"));
4611        assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
4612        assert!(file.contains("createPreparedInstruction({"));
4613    }
4614
4615    #[test]
4616    fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
4617        let path = concat!(
4618            env!("CARGO_MANIFEST_DIR"),
4619            "/../stacks/ore/.arete/OreStream.stack.json"
4620        );
4621        let json = match std::fs::read_to_string(path) {
4622            Ok(c) => c,
4623            Err(_) => return,
4624        };
4625        let spec: SerializableStackSpec =
4626            serde_json::from_str(&json).expect("ore stack json should deserialize");
4627
4628        let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
4629        let file = output.full_file();
4630
4631        assert!(
4632            file.contains("endpoints:"),
4633            "stack endpoints missing:\n{}",
4634            file
4635        );
4636        assert!(
4637            file.contains("programs: {"),
4638            "program block missing:\n{}",
4639            file
4640        );
4641        assert!(
4642            file.contains("addresses: {"),
4643            "root addresses missing:\n{}",
4644            file
4645        );
4646        assert!(
4647            file.contains("instructions: {"),
4648            "program instructions missing:\n{}",
4649            file
4650        );
4651        assert!(
4652            file.contains("buildInstruction("),
4653            "instruction builders missing:\n{}",
4654            file
4655        );
4656    }
4657
4658    #[test]
4659    fn account_codegen_normalizes_raw_keys_and_nested_types() {
4660        let idl_snapshot = IdlSnapshot {
4661            name: "presale".to_string(),
4662            program_id: None,
4663            version: "0.1.0".to_string(),
4664            accounts: vec![IdlAccountSnapshot {
4665                name: "Presale".to_string(),
4666                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
4667                docs: vec![],
4668                serialization: None,
4669                fields: vec![
4670                    IdlFieldSnapshot {
4671                        name: "owner".to_string(),
4672                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4673                        amount_hint: None,
4674                    },
4675                    IdlFieldSnapshot {
4676                        name: "total_deposit".to_string(),
4677                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
4678                        amount_hint: None,
4679                    },
4680                    IdlFieldSnapshot {
4681                        name: "optional_authority".to_string(),
4682                        type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
4683                            option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
4684                        }),
4685                        amount_hint: None,
4686                    },
4687                    IdlFieldSnapshot {
4688                        name: "createKey".to_string(),
4689                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4690                        amount_hint: None,
4691                    },
4692                    IdlFieldSnapshot {
4693                        name: "member".to_string(),
4694                        type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
4695                            defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
4696                        }),
4697                        amount_hint: None,
4698                    },
4699                ],
4700                type_def: None,
4701            }],
4702            instructions: vec![],
4703            types: vec![IdlTypeDefSnapshot {
4704                name: "MemberConfig".to_string(),
4705                docs: vec![],
4706                serialization: None,
4707                type_def: IdlTypeDefKindSnapshot::Struct {
4708                    kind: "struct".to_string(),
4709                    fields: vec![
4710                        IdlFieldSnapshot {
4711                            name: "last_updated_at".to_string(),
4712                            type_: IdlTypeSnapshot::Simple("i128".to_string()),
4713                            amount_hint: None,
4714                        },
4715                        IdlFieldSnapshot {
4716                            name: "authority_key".to_string(),
4717                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4718                            amount_hint: None,
4719                        },
4720                    ],
4721                },
4722            }],
4723            events: vec![],
4724            errors: vec![],
4725            discriminant_size: 8,
4726        };
4727
4728        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
4729        let account_bigint = bigint_zod();
4730
4731        assert!(artifacts.code.contains("export interface Presale {"));
4732        assert!(
4733            artifacts.code.contains("owner: string;"),
4734            "missing owner field:\n{}",
4735            artifacts.code
4736        );
4737        assert!(
4738            artifacts.code.contains("totalDeposit: bigint;"),
4739            "missing totalDeposit field:\n{}",
4740            artifacts.code
4741        );
4742        assert!(
4743            artifacts.code.contains("optionalAuthority: string | null;"),
4744            "missing optionalAuthority field:\n{}",
4745            artifacts.code
4746        );
4747        assert!(
4748            artifacts.code.contains("createKey: string;"),
4749            "missing createKey field:\n{}",
4750            artifacts.code
4751        );
4752        assert!(
4753            artifacts.code.contains("member: MemberConfig;"),
4754            "missing nested type field:\n{}",
4755            artifacts.code
4756        );
4757        assert!(artifacts.code.contains("export interface MemberConfig {"));
4758        assert!(
4759            artifacts.code.contains("lastUpdatedAt: bigint;"),
4760            "missing nested bigint field:\n{}",
4761            artifacts.code
4762        );
4763        assert!(
4764            artifacts.code.contains("authorityKey: string;"),
4765            "missing nested camelCase field:\n{}",
4766            artifacts.code
4767        );
4768
4769        assert!(artifacts
4770            .code
4771            .contains("export const PresaleSchema = z.object({"));
4772        assert!(
4773            artifacts.code.contains("owner: z.string(),"),
4774            "missing owner schema field:\n{}",
4775            artifacts.code
4776        );
4777        assert!(
4778            artifacts
4779                .code
4780                .contains(&format!("total_deposit: {},", account_bigint)),
4781            "missing total_deposit schema field:\n{}",
4782            artifacts.code
4783        );
4784        assert!(
4785            artifacts
4786                .code
4787                .contains("optional_authority: z.string().nullable(),"),
4788            "missing optional_authority schema field:\n{}",
4789            artifacts.code
4790        );
4791        assert!(
4792            artifacts.code.contains("create_key: z.string(),"),
4793            "missing create_key schema field:\n{}",
4794            artifacts.code
4795        );
4796        assert!(
4797            artifacts
4798                .code
4799                .contains("member: z.lazy(() => MemberConfigSchema),"),
4800            "missing nested schema field:\n{}",
4801            artifacts.code
4802        );
4803        assert!(
4804            artifacts.code.contains("owner: value.owner,"),
4805            "missing owner transform:\n{}",
4806            artifacts.code
4807        );
4808        assert!(
4809            artifacts
4810                .code
4811                .contains("totalDeposit: value.total_deposit,"),
4812            "missing totalDeposit transform:\n{}",
4813            artifacts.code
4814        );
4815        assert!(
4816            artifacts
4817                .code
4818                .contains("optionalAuthority: value.optional_authority,"),
4819            "missing optionalAuthority transform:\n{}",
4820            artifacts.code
4821        );
4822        assert!(
4823            artifacts.code.contains("createKey: value.create_key,"),
4824            "missing createKey transform:\n{}",
4825            artifacts.code
4826        );
4827        assert!(
4828            artifacts.code.contains("member: value.member,"),
4829            "missing nested transform:\n{}",
4830            artifacts.code
4831        );
4832
4833        assert!(artifacts
4834            .code
4835            .contains("export const MemberConfigSchema = z.object({"));
4836        assert!(
4837            artifacts
4838                .code
4839                .contains(&format!("last_updated_at: {},", bigint_zod())),
4840            "missing nested bigint schema field:\n{}",
4841            artifacts.code
4842        );
4843        assert!(
4844            artifacts.code.contains("authority_key: z.string(),"),
4845            "missing nested schema field:\n{}",
4846            artifacts.code
4847        );
4848        assert!(
4849            artifacts
4850                .code
4851                .contains("lastUpdatedAt: value.last_updated_at,"),
4852            "missing nested bigint transform:\n{}",
4853            artifacts.code
4854        );
4855        assert!(
4856            artifacts
4857                .code
4858                .contains("authorityKey: value.authority_key,"),
4859            "missing nested camelCase transform:\n{}",
4860            artifacts.code
4861        );
4862    }
4863
4864    #[test]
4865    fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
4866        let idl_snapshot = IdlSnapshot {
4867            name: "presale".to_string(),
4868            program_id: None,
4869            version: "0.1.0".to_string(),
4870            accounts: vec![IdlAccountSnapshot {
4871                name: "Presale".to_string(),
4872                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
4873                docs: vec![],
4874                serialization: None,
4875                fields: vec![],
4876                type_def: None,
4877            }],
4878            instructions: vec![],
4879            types: vec![IdlTypeDefSnapshot {
4880                name: "Presale".to_string(),
4881                docs: vec![],
4882                serialization: None,
4883                type_def: IdlTypeDefKindSnapshot::Struct {
4884                    kind: "struct".to_string(),
4885                    fields: vec![
4886                        IdlFieldSnapshot {
4887                            name: "owner".to_string(),
4888                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
4889                            amount_hint: None,
4890                        },
4891                        IdlFieldSnapshot {
4892                            name: "total_deposit".to_string(),
4893                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
4894                            amount_hint: None,
4895                        },
4896                    ],
4897                },
4898            }],
4899            events: vec![],
4900            errors: vec![],
4901            discriminant_size: 8,
4902        };
4903
4904        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
4905
4906        assert!(artifacts.code.contains("export interface Presale {"));
4907        assert!(
4908            artifacts.code.contains("owner: string;"),
4909            "missing owner field:\n{}",
4910            artifacts.code
4911        );
4912        assert!(
4913            artifacts.code.contains("totalDeposit: bigint;"),
4914            "missing totalDeposit field:\n{}",
4915            artifacts.code
4916        );
4917        assert!(
4918            artifacts
4919                .code
4920                .contains("export const PresaleSchema = z.object({"),
4921            "missing schema:\n{}",
4922            artifacts.code
4923        );
4924        assert!(
4925            artifacts.code.contains("owner: z.string(),"),
4926            "missing owner schema field:\n{}",
4927            artifacts.code
4928        );
4929        assert!(
4930            artifacts
4931                .code
4932                .contains(&format!("total_deposit: {},", bigint_zod())),
4933            "missing total_deposit schema field:\n{}",
4934            artifacts.code
4935        );
4936        assert!(
4937            artifacts.code.contains("owner: value.owner,"),
4938            "missing owner transform:\n{}",
4939            artifacts.code
4940        );
4941        assert!(
4942            artifacts
4943                .code
4944                .contains("totalDeposit: value.total_deposit,"),
4945            "missing totalDeposit transform:\n{}",
4946            artifacts.code
4947        );
4948    }
4949
4950    #[test]
4951    fn compile_program_modules_rejects_specs_without_idls() {
4952        let stack_spec = SerializableStackSpec {
4953            ast_version: CURRENT_AST_VERSION.to_string(),
4954            stack_name: "Empty".to_string(),
4955            program_ids: vec![],
4956            idls: vec![],
4957            entities: vec![],
4958            pdas: BTreeMap::new(),
4959            instructions: vec![],
4960            content_hash: None,
4961        };
4962
4963        let error =
4964            compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
4965        assert!(error.contains("no IDLs"), "unexpected error: {}", error);
4966    }
4967}