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
72#[derive(Debug, Clone, PartialEq, Eq)]
73struct StateViewKeyDefinition {
74    field_name: String,
75    typescript_type: String,
76}
77
78impl StateViewKeyDefinition {
79    fn object_type(&self) -> String {
80        format!(
81            "{{ {}: {} }}",
82            render_ts_property_name_literal(&self.field_name),
83            self.typescript_type
84        )
85    }
86
87    fn fields_literal(&self) -> String {
88        format!("['{}']", escape_ts_single_quotes(&self.field_name))
89    }
90}
91
92impl<S> TypeScriptCompiler<S> {
93    pub fn new(spec: TypedStreamSpec<S>, entity_name: String) -> Self {
94        Self {
95            spec,
96            entity_name,
97            config: TypeScriptConfig::default(),
98            idl: None,
99            handlers_json: None,
100            views: Vec::new(),
101            already_emitted_types: HashSet::new(),
102        }
103    }
104
105    pub fn with_config(mut self, config: TypeScriptConfig) -> Self {
106        self.config = config;
107        self
108    }
109
110    pub fn with_idl(mut self, idl: Option<serde_json::Value>) -> Self {
111        self.idl = idl;
112        self
113    }
114
115    pub fn with_handlers_json(mut self, handlers: Option<serde_json::Value>) -> Self {
116        self.handlers_json = handlers;
117        self
118    }
119
120    pub fn with_views(mut self, views: Vec<ViewDef>) -> Self {
121        self.views = views;
122        self
123    }
124
125    pub fn with_already_emitted_types(mut self, types: HashSet<String>) -> Self {
126        self.already_emitted_types = types;
127        self
128    }
129
130    pub fn compile(&self) -> TypeScriptOutput {
131        self.try_compile()
132            .expect("TypeScript SDK generation failed")
133    }
134
135    pub fn try_compile(&self) -> Result<TypeScriptOutput, String> {
136        let state_view_key = state_view_key_definition(
137            &self.entity_name,
138            &self.spec.identity,
139            &self.spec.field_mappings,
140            &self.spec.sections,
141        )?;
142        let imports = self.generate_imports();
143        let interfaces = self.generate_interfaces();
144        let schema_output = self.generate_schemas();
145        let combined_interfaces = if schema_output.definitions.is_empty() {
146            interfaces
147        } else if interfaces.is_empty() {
148            schema_output.definitions.clone()
149        } else {
150            format!("{}\n\n{}", interfaces, schema_output.definitions)
151        };
152        let stack_definition = self.generate_stack_definition(&state_view_key);
153
154        Ok(TypeScriptOutput {
155            imports,
156            interfaces: combined_interfaces,
157            stack_definition,
158            schema_names: schema_output.names,
159        })
160    }
161
162    fn generate_imports(&self) -> String {
163        "import { z } from 'zod';".to_string()
164    }
165
166    fn generate_view_helpers(&self) -> String {
167        generate_view_helpers_static()
168    }
169
170    fn generate_interfaces(&self) -> String {
171        let mut interfaces = Vec::new();
172        let mut processed_types = HashSet::new();
173        let all_sections = self.collect_interface_sections();
174
175        // Deduplicate fields within each section and generate interfaces
176        // Skip root section - its fields will be flattened into main entity interface
177        for (section_name, fields) in all_sections {
178            if !is_root_section(&section_name) && processed_types.insert(section_name.clone()) {
179                let deduplicated_fields = self.deduplicate_fields(fields);
180                let interface =
181                    self.generate_interface_from_fields(&section_name, &deduplicated_fields);
182                interfaces.push(interface);
183            }
184        }
185
186        // Generate main entity interface
187        let main_interface = self.generate_main_entity_interface();
188        interfaces.push(main_interface);
189
190        let nested_interfaces = self.generate_nested_interfaces();
191        interfaces.extend(nested_interfaces);
192
193        let builtin_interfaces = self.generate_builtin_resolver_interfaces();
194        interfaces.extend(builtin_interfaces);
195
196        if self.should_emit_capture_wrapper() {
197            interfaces.push(self.generate_capture_wrapper_interface());
198        }
199
200        if self.has_event_types() {
201            interfaces.push(self.generate_event_wrapper_interface());
202        }
203
204        interfaces.join("\n\n")
205    }
206
207    fn collect_interface_sections(&self) -> BTreeMap<String, Vec<TypeScriptField>> {
208        let mut all_sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
209
210        // Collect all interface sections from all handlers
211        for handler in &self.spec.handlers {
212            let interface_sections = self.extract_interface_sections_from_handler(handler);
213
214            for (section_name, mut fields) in interface_sections {
215                all_sections
216                    .entry(section_name)
217                    .or_default()
218                    .append(&mut fields);
219            }
220        }
221
222        // Add unmapped fields from spec.sections ONCE (not per handler)
223        // These are fields without #[map] or #[event] attributes
224        self.add_unmapped_fields(&mut all_sections);
225
226        all_sections
227    }
228
229    fn deduplicate_fields(&self, mut fields: Vec<TypeScriptField>) -> Vec<TypeScriptField> {
230        let mut seen = HashSet::new();
231        let mut unique_fields = Vec::new();
232
233        // Sort fields by name for consistent output
234        fields.sort_by(|a, b| a.name.cmp(&b.name));
235
236        for field in fields {
237            if seen.insert(field.name.clone()) {
238                unique_fields.push(field);
239            }
240        }
241
242        unique_fields
243    }
244
245    fn extract_interface_sections_from_handler(
246        &self,
247        handler: &TypedHandlerSpec<S>,
248    ) -> BTreeMap<String, Vec<TypeScriptField>> {
249        let mut sections: BTreeMap<String, Vec<TypeScriptField>> = BTreeMap::new();
250
251        for mapping in &handler.mappings {
252            if !mapping.emit {
253                continue;
254            }
255            let parts: Vec<&str> = mapping.target_path.split('.').collect();
256
257            if parts.len() > 1 {
258                let section_name = parts[0];
259                let field_name = parts[1];
260
261                let ts_field = TypeScriptField::patch(
262                    field_name.to_string(),
263                    self.mapping_to_typescript_type(mapping),
264                    self.is_field_nullable(mapping),
265                );
266
267                sections
268                    .entry(section_name.to_string())
269                    .or_default()
270                    .push(ts_field);
271            } else {
272                let ts_field = TypeScriptField::patch(
273                    mapping.target_path.clone(),
274                    self.mapping_to_typescript_type(mapping),
275                    self.is_field_nullable(mapping),
276                );
277
278                sections
279                    .entry("Root".to_string())
280                    .or_default()
281                    .push(ts_field);
282            }
283        }
284
285        sections
286    }
287
288    fn add_unmapped_fields(&self, sections: &mut BTreeMap<String, Vec<TypeScriptField>>) {
289        // NEW: Enhanced approach using AST type information if available
290        if !self.spec.sections.is_empty() {
291            // Use type information from the enhanced AST
292            for section in &self.spec.sections {
293                let section_fields = sections.entry(section.name.clone()).or_default();
294
295                for field_info in &section.fields {
296                    if !field_info.emit {
297                        continue;
298                    }
299                    // Check if field is already mapped
300                    let already_exists = section_fields
301                        .iter()
302                        .any(|f| f.name == field_info.field_name);
303
304                    if !already_exists {
305                        // For computed fields, check field_mappings for resolver type info
306                        let field_path = format!("{}.{}", section.name, field_info.field_name);
307                        let effective_field_info =
308                            if let Some(mapping) = self.spec.field_mappings.get(&field_path) {
309                                // Use mapping's inner_type if it's a resolver output type
310                                if mapping
311                                    .inner_type
312                                    .as_ref()
313                                    .is_some_and(|t| is_builtin_resolver_type(t))
314                                {
315                                    mapping
316                                } else {
317                                    field_info
318                                }
319                            } else {
320                                field_info
321                            };
322                        let (raw_name, canonical_name) = localize_section_field_names(
323                            section.name.as_str(),
324                            effective_field_info,
325                        );
326
327                        section_fields.push(TypeScriptField::from_names(
328                            raw_name,
329                            canonical_name,
330                            self.field_type_info_to_typescript(effective_field_info),
331                            effective_field_info.is_optional,
332                            FieldPresence::Patch,
333                        ));
334                    }
335                }
336            }
337        } else {
338            // FALLBACK: Use field mappings from spec if sections aren't available yet
339            for (field_path, field_type_info) in &self.spec.field_mappings {
340                if !field_type_info.emit {
341                    continue;
342                }
343                let parts: Vec<&str> = field_path.split('.').collect();
344                if parts.len() > 1 {
345                    let section_name = parts[0];
346                    let field_name = parts[1];
347
348                    let section_fields = sections.entry(section_name.to_string()).or_default();
349
350                    let already_exists = section_fields.iter().any(|f| f.name == field_name);
351
352                    if !already_exists {
353                        section_fields.push(TypeScriptField::from_names(
354                            field_name.to_string(),
355                            field_type_info.canonical_field_name(),
356                            self.base_type_to_typescript(
357                                &field_type_info.base_type,
358                                field_type_info.effective_integer_kind(),
359                                field_type_info.is_array,
360                            ),
361                            field_type_info.is_optional,
362                            FieldPresence::Patch,
363                        ));
364                    }
365                }
366            }
367        }
368    }
369
370    fn generate_interface_from_fields(&self, name: &str, fields: &[TypeScriptField]) -> String {
371        let interface_name = self.section_interface_name(name);
372        render_interface_from_ts_fields(&interface_name, fields, true)
373    }
374
375    fn section_interface_name(&self, name: &str) -> String {
376        if name == "Root" {
377            format!(
378                "{}{}",
379                self.config.interface_prefix,
380                to_pascal_case(&self.entity_name)
381            )
382        } else {
383            // Create compound names like GameEvents, GameStatus, etc.
384            // Extract the base name (e.g., "Game" from "TestGame" or "SettlementGame")
385            let base_name = if self.entity_name.contains("Game") {
386                "Game"
387            } else {
388                &self.entity_name
389            };
390            format!(
391                "{}{}{}",
392                self.config.interface_prefix,
393                base_name,
394                to_pascal_case(name)
395            )
396        }
397    }
398
399    fn generate_main_entity_interface(&self) -> String {
400        let entity_name = to_pascal_case(&self.entity_name);
401
402        let main_fields = self.collect_main_entity_fields();
403        if main_fields.is_empty() {
404            return format!(
405                "export interface {} {{\n  // Generated interface - extend as needed\n}}",
406                entity_name
407            );
408        }
409
410        render_interface_from_ts_fields(&entity_name, &main_fields, true)
411    }
412
413    fn generate_schemas(&self) -> SchemaOutput {
414        let patch_schema_types = self.patch_schema_type_names();
415        let mut definitions = Vec::new();
416        let mut names = Vec::new();
417        let mut seen = HashSet::new();
418
419        let mut push_schema = |schema_name: String, definition: String, export_name: bool| {
420            if seen.insert(schema_name.clone()) {
421                if export_name {
422                    names.push(schema_name);
423                }
424                definitions.push(definition);
425            }
426        };
427
428        for (schema_name, definition) in self.generate_builtin_resolver_schemas() {
429            push_schema(schema_name, definition, true);
430        }
431
432        if self.has_event_types() {
433            push_schema(
434                "EventWrapperSchema".to_string(),
435                self.generate_event_wrapper_schema(),
436                true,
437            );
438        }
439
440        if self.should_emit_capture_wrapper() {
441            push_schema(
442                "CaptureWrapperSchema".to_string(),
443                self.generate_capture_wrapper_schema(),
444                false,
445            );
446        }
447
448        for (schema_name, definition) in self.generate_resolved_type_schemas(&patch_schema_types) {
449            push_schema(schema_name, definition, true);
450        }
451
452        for (schema_name, definition) in
453            self.generate_resolved_type_patch_schemas(&patch_schema_types)
454        {
455            push_schema(schema_name, definition, false);
456        }
457
458        for (schema_name, definition) in self.generate_event_schemas() {
459            push_schema(schema_name, definition, true);
460        }
461
462        for (schema_name, definition) in self.generate_idl_enum_schemas() {
463            push_schema(schema_name, definition, true);
464        }
465
466        let all_sections = self.collect_interface_sections();
467
468        for (section_name, fields) in &all_sections {
469            if is_root_section(section_name) {
470                continue;
471            }
472            let deduplicated_fields = self.deduplicate_fields(fields.clone());
473            let interface_name = self.section_interface_name(section_name);
474            let schema_definition = self.generate_schema_for_fields(
475                &interface_name,
476                &deduplicated_fields,
477                true,
478                SchemaMode::Canonical,
479                &patch_schema_types,
480            );
481            push_schema(format!("{}Schema", interface_name), schema_definition, true);
482
483            let patch_schema_definition = self.generate_schema_for_fields(
484                &interface_name,
485                &deduplicated_fields,
486                false,
487                SchemaMode::Patch,
488                &patch_schema_types,
489            );
490            push_schema(
491                format!("{}PatchSchema", interface_name),
492                patch_schema_definition,
493                false,
494            );
495        }
496
497        let entity_name = to_pascal_case(&self.entity_name);
498        let main_fields = self.collect_main_entity_fields();
499        let entity_schema = self.generate_schema_for_fields(
500            &entity_name,
501            &main_fields,
502            true,
503            SchemaMode::Canonical,
504            &patch_schema_types,
505        );
506        push_schema(format!("{}Schema", entity_name), entity_schema, true);
507
508        let patch_schema = self.generate_schema_for_fields(
509            &entity_name,
510            &main_fields,
511            false,
512            SchemaMode::Patch,
513            &patch_schema_types,
514        );
515        push_schema(format!("{}PatchSchema", entity_name), patch_schema, false);
516
517        let completed_schema =
518            self.generate_completed_entity_schema(&entity_name, &patch_schema_types);
519        push_schema(
520            format!("{}CompletedSchema", entity_name),
521            completed_schema,
522            true,
523        );
524
525        SchemaOutput {
526            definitions: definitions.join("\n\n"),
527            names,
528        }
529    }
530
531    fn generate_event_wrapper_schema(&self) -> String {
532        r#"export const EventWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
533  timestamp: z.number(),
534  data,
535  slot: z.number().optional(),
536  signature: z.string().optional(),
537});"#
538            .to_string()
539    }
540
541    fn generate_capture_wrapper_schema(&self) -> String {
542        r#"export const CaptureWrapperSchema = <T extends z.ZodTypeAny>(data: T) => z.object({
543  timestamp: z.number(),
544  account_address: z.string(),
545  data,
546  slot: z.number().optional(),
547  signature: z.string().optional(),
548}).transform((value) => ({
549  timestamp: value.timestamp,
550  accountAddress: value.account_address,
551  data: value.data,
552  ...(value.slot !== undefined ? { slot: value.slot } : {}),
553  ...(value.signature !== undefined ? { signature: value.signature } : {}),
554}));"#
555            .to_string()
556    }
557
558    fn generate_builtin_resolver_schemas(&self) -> Vec<(String, String)> {
559        let mut schemas = Vec::new();
560        let registry = crate::resolvers::builtin_resolver_registry();
561
562        for resolver in registry.definitions() {
563            let output_type = resolver.output_type();
564            let should_emit = self.uses_builtin_type(output_type)
565                && !self.already_emitted_types.contains(output_type);
566
567            // Also check if any types from the resolver's typescript_schema are used
568            let extra_types_used = if let Some(ts_schema) = resolver.typescript_schema() {
569                // Extract type names from export statements (simple string parsing)
570                ts_schema.definition.lines().any(|line| {
571                    let line = line.trim();
572                    // Match "export const TypeNameSchema"
573                    if let Some(rest) = line.strip_prefix("export const ") {
574                        let parts: Vec<&str> = rest.split_whitespace().collect();
575                        if parts.len() >= 2 && parts[1] == "=" {
576                            // Extract the base type name from "TypeNameSchema"
577                            let schema_name = parts[0];
578                            if let Some(type_name) = schema_name.strip_suffix("Schema") {
579                                return self.uses_builtin_type(type_name)
580                                    && !self.already_emitted_types.contains(type_name);
581                            }
582                        }
583                    }
584                    false
585                })
586            } else {
587                false
588            };
589
590            if (should_emit || extra_types_used)
591                && !self.already_emitted_types.contains(output_type)
592            {
593                if let Some(schema) = resolver.typescript_schema() {
594                    schemas.push((schema.name.to_string(), schema.definition.to_string()));
595                }
596            }
597        }
598
599        schemas
600    }
601
602    fn uses_builtin_type(&self, type_name: &str) -> bool {
603        // Check section fields
604        for section in &self.spec.sections {
605            for field in &section.fields {
606                if field.inner_type.as_deref() == Some(type_name) {
607                    return true;
608                }
609            }
610        }
611        // Check field_mappings for computed fields (they may have resolver types not in sections)
612        for field_info in self.spec.field_mappings.values() {
613            if field_info.inner_type.as_deref() == Some(type_name) {
614                return true;
615            }
616        }
617        false
618    }
619
620    fn generate_builtin_resolver_interfaces(&self) -> Vec<String> {
621        let mut interfaces = Vec::new();
622        let registry = crate::resolvers::builtin_resolver_registry();
623
624        for resolver in registry.definitions() {
625            let output_type = resolver.output_type();
626            let should_emit = self.uses_builtin_type(output_type)
627                && !self.already_emitted_types.contains(output_type);
628
629            // Also check if any types from the resolver's typescript_interface are used
630            let extra_types_used = if let Some(ts_interface) = resolver.typescript_interface() {
631                // Extract type names from export statements (simple string parsing)
632                ts_interface.lines().any(|line| {
633                    let line = line.trim();
634                    // Match "export type TypeName" or "export interface TypeName"
635                    if let Some(rest) = line.strip_prefix("export type ") {
636                        if let Some(type_name) = rest.split_whitespace().next() {
637                            return self.uses_builtin_type(type_name)
638                                && !self.already_emitted_types.contains(type_name);
639                        }
640                    } else if let Some(rest) = line.strip_prefix("export interface ") {
641                        if let Some(type_name) = rest.split_whitespace().next() {
642                            return self.uses_builtin_type(type_name)
643                                && !self.already_emitted_types.contains(type_name);
644                        }
645                    }
646                    false
647                })
648            } else {
649                false
650            };
651
652            if should_emit || extra_types_used {
653                if let Some(interface) = resolver.typescript_interface() {
654                    interfaces.push(interface.to_string());
655                }
656            }
657        }
658
659        interfaces
660    }
661
662    fn collect_main_entity_fields(&self) -> Vec<TypeScriptField> {
663        let mut sections = BTreeMap::new();
664
665        for handler in &self.spec.handlers {
666            for mapping in &handler.mappings {
667                if !mapping.emit {
668                    continue;
669                }
670                let parts: Vec<&str> = mapping.target_path.split('.').collect();
671                if parts.len() > 1 {
672                    sections.insert(parts[0], true);
673                }
674            }
675        }
676
677        if !self.spec.sections.is_empty() {
678            for section in &self.spec.sections {
679                if section.fields.iter().any(|field| field.emit) {
680                    sections.insert(&section.name, true);
681                }
682            }
683        } else {
684            for mapping in &self.spec.handlers {
685                for field_mapping in &mapping.mappings {
686                    if !field_mapping.emit {
687                        continue;
688                    }
689                    let parts: Vec<&str> = field_mapping.target_path.split('.').collect();
690                    if parts.len() > 1 {
691                        sections.insert(parts[0], true);
692                    }
693                }
694            }
695        }
696
697        let mut fields = Vec::new();
698
699        for section in sections.keys() {
700            if !is_root_section(section) {
701                let base_name = if self.entity_name.contains("Game") {
702                    "Game"
703                } else {
704                    &self.entity_name
705                };
706                let section_interface_name = format!("{}{}", base_name, to_pascal_case(section));
707                fields.push(TypeScriptField::patch(
708                    section.to_string(),
709                    section_interface_name,
710                    false,
711                ));
712            }
713        }
714
715        for section in &self.spec.sections {
716            if is_root_section(&section.name) {
717                for field in &section.fields {
718                    if !field.emit {
719                        continue;
720                    }
721                    fields.push(TypeScriptField::from_names(
722                        field.raw_field_name().to_string(),
723                        field.canonical_field_name(),
724                        self.field_type_info_to_typescript(field),
725                        field.is_optional,
726                        FieldPresence::Patch,
727                    ));
728                }
729            }
730        }
731
732        fields
733    }
734
735    fn generate_schema_for_fields(
736        &self,
737        name: &str,
738        fields: &[TypeScriptField],
739        required: bool,
740        mode: SchemaMode,
741        patch_schema_types: &HashSet<String>,
742    ) -> String {
743        if fields.is_empty() {
744            return format!(
745                "export const {} = z.object({{}});",
746                schema_constant_name(name, mode)
747            );
748        }
749
750        let mut field_definitions = Vec::new();
751        let mut transform_fields = Vec::new();
752
753        for field in fields {
754            let base_schema = field.zod_schema.clone().unwrap_or_else(|| {
755                self.typescript_type_to_zod_for_schema(&field.ts_type, mode, patch_schema_types)
756            });
757            let with_nullable = if field.nullable {
758                format!("{}.nullable()", base_schema)
759            } else {
760                base_schema
761            };
762            let nullable_keys_can_be_absent = matches!(mode, SchemaMode::Canonical);
763            let schema = if required || matches!(field.presence, FieldPresence::Required) {
764                if field.nullable && nullable_keys_can_be_absent {
765                    // Nullable entity fields are projected with LastWrite semantics and may be
766                    // absent until a value exists. Keep them nullable and key-optional in
767                    // completed schemas so partial hydration never rejects the entity.
768                    format!("{}.optional()", with_nullable)
769                } else {
770                    with_nullable
771                }
772            } else {
773                format!("{}.optional()", with_nullable)
774            };
775
776            field_definitions.push(format!("  {}: {},", field.raw_name, schema));
777            if mode == SchemaMode::Patch {
778                transform_fields.push(format!(
779                    "  ...(value.{raw_name} !== undefined ? {{ {field_name}: value.{raw_name} }} : {{}}),",
780                    raw_name = field.raw_name,
781                    field_name = field.name,
782                ));
783            } else {
784                transform_fields.push(format!("  {}: value.{},", field.name, field.raw_name));
785            }
786        }
787
788        format!(
789            "export const {} = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
790            schema_constant_name(name, mode),
791            field_definitions.join("\n"),
792            transform_fields.join("\n")
793        )
794    }
795
796    fn generate_completed_entity_schema(
797        &self,
798        entity_name: &str,
799        patch_schema_types: &HashSet<String>,
800    ) -> String {
801        let main_fields = self.collect_main_entity_fields();
802        self.generate_schema_for_fields(
803            &format!("{}Completed", entity_name),
804            &main_fields,
805            true,
806            SchemaMode::Canonical,
807            patch_schema_types,
808        )
809    }
810
811    fn generate_resolved_type_schemas(
812        &self,
813        patch_schema_types: &HashSet<String>,
814    ) -> Vec<(String, String)> {
815        let mut schemas = Vec::new();
816        let mut generated_types = HashSet::new();
817        let resolved_name_map = self.build_resolved_type_name_map();
818
819        for section in &self.spec.sections {
820            for field_info in &section.fields {
821                if let Some(resolved) = &field_info.resolved_type {
822                    let type_name =
823                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
824
825                    if !generated_types.insert(type_name.clone()) {
826                        continue;
827                    }
828
829                    if resolved.is_enum {
830                        let variants: Vec<String> = resolved
831                            .enum_variants
832                            .iter()
833                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
834                            .collect();
835                        let schema = if variants.is_empty() {
836                            format!("export const {}Schema = z.string();", type_name)
837                        } else {
838                            format!(
839                                "export const {}Schema = z.enum([{}]);",
840                                type_name,
841                                variants.join(", ")
842                            )
843                        };
844                        schemas.push((format!("{}Schema", type_name), schema));
845                        continue;
846                    }
847
848                    let schema = self.generate_schema_for_fields(
849                        &type_name,
850                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
851                        true,
852                        SchemaMode::Canonical,
853                        patch_schema_types,
854                    );
855                    schemas.push((format!("{}Schema", type_name), schema));
856                }
857            }
858        }
859
860        schemas
861    }
862
863    fn generate_resolved_type_patch_schemas(
864        &self,
865        patch_schema_types: &HashSet<String>,
866    ) -> Vec<(String, String)> {
867        let mut schemas = Vec::new();
868        let mut generated_types = HashSet::new();
869        let resolved_name_map = self.build_resolved_type_name_map();
870
871        for section in &self.spec.sections {
872            for field_info in &section.fields {
873                if let Some(resolved) = &field_info.resolved_type {
874                    let type_name =
875                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
876
877                    if !generated_types.insert(type_name.clone()) || resolved.is_enum {
878                        continue;
879                    }
880
881                    let schema = self.generate_schema_for_fields(
882                        &type_name,
883                        &self.resolved_fields_to_typescript_fields(&resolved.fields),
884                        false,
885                        SchemaMode::Patch,
886                        patch_schema_types,
887                    );
888                    schemas.push((format!("{}PatchSchema", type_name), schema));
889                }
890            }
891        }
892
893        schemas
894    }
895
896    fn generate_event_schemas(&self) -> Vec<(String, String)> {
897        let mut schemas = Vec::new();
898        let mut generated_types = HashSet::new();
899
900        let handlers = match &self.handlers_json {
901            Some(h) => h.as_array(),
902            None => return schemas,
903        };
904
905        let handlers_array = match handlers {
906            Some(arr) => arr,
907            None => return schemas,
908        };
909
910        for handler in handlers_array {
911            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
912                for mapping in mappings {
913                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
914                        if target_path.contains(".events.") || target_path.starts_with("events.") {
915                            if let Some(source) = mapping.get("source") {
916                                if let Some(event_data) = self.extract_event_data(source) {
917                                    if let Some(handler_source) = handler.get("source") {
918                                        if let Some(instruction_name) =
919                                            self.extract_instruction_name(handler_source)
920                                        {
921                                            let event_field_name =
922                                                target_path.split('.').next_back().unwrap_or("");
923                                            let interface_name = format!(
924                                                "{}Event",
925                                                to_pascal_case(event_field_name)
926                                            );
927
928                                            if generated_types.insert(interface_name.clone()) {
929                                                if let Some(schema) = self
930                                                    .generate_event_schema_from_idl(
931                                                        &interface_name,
932                                                        &instruction_name,
933                                                        &event_data,
934                                                    )
935                                                {
936                                                    schemas.push((
937                                                        format!("{}Schema", interface_name),
938                                                        schema,
939                                                    ));
940                                                }
941                                            }
942                                        }
943                                    }
944                                }
945                            }
946                        }
947                    }
948                }
949            }
950        }
951
952        schemas
953    }
954
955    fn generate_event_schema_from_idl(
956        &self,
957        interface_name: &str,
958        rust_instruction_name: &str,
959        captured_fields: &[(String, Option<String>)],
960    ) -> Option<String> {
961        if captured_fields.is_empty() {
962            return Some(format!(
963                "export const {}Schema = z.object({{}});",
964                interface_name
965            ));
966        }
967
968        let idl_value = self.idl.as_ref()?;
969        let instructions = idl_value.get("instructions")?.as_array()?;
970
971        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
972        let args = instruction.get("args")?.as_array()?;
973
974        let mut fields = Vec::new();
975        for (field_name, transform) in captured_fields {
976            for arg in args {
977                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
978                    if arg_name == field_name {
979                        if let Some(arg_type) = arg.get("type") {
980                            let ts_type =
981                                self.idl_type_to_typescript(arg_type, transform.as_deref());
982                            fields.push(TypeScriptField::patch(
983                                field_name.to_string(),
984                                ts_type,
985                                false,
986                            ));
987                        }
988                        break;
989                    }
990                }
991            }
992        }
993
994        Some(render_schema_from_ts_fields(interface_name, &fields, true))
995    }
996
997    fn generate_idl_enum_schemas(&self) -> Vec<(String, String)> {
998        let mut schemas = Vec::new();
999        let mut generated_types = self.already_emitted_types.clone();
1000
1001        let idl_value = match &self.idl {
1002            Some(idl) => idl,
1003            None => return schemas,
1004        };
1005
1006        let types_array = match idl_value.get("types").and_then(|v| v.as_array()) {
1007            Some(types) => types,
1008            None => return schemas,
1009        };
1010
1011        for type_def in types_array {
1012            if let (Some(type_name), Some(type_obj)) = (
1013                type_def.get("name").and_then(|v| v.as_str()),
1014                type_def.get("type").and_then(|v| v.as_object()),
1015            ) {
1016                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1017                    let interface_name = to_pascal_case(type_name);
1018                    if !generated_types.insert(interface_name.clone()) {
1019                        continue;
1020                    }
1021                    if let Some(variants) = type_obj.get("variants").and_then(|v| v.as_array()) {
1022                        let variant_names: Vec<String> = variants
1023                            .iter()
1024                            .filter_map(|v| v.get("name").and_then(|n| n.as_str()))
1025                            .map(|s| format!("\"{}\"", to_pascal_case(s)))
1026                            .collect();
1027
1028                        let schema = if variant_names.is_empty() {
1029                            format!("export const {}Schema = z.string();", interface_name)
1030                        } else {
1031                            format!(
1032                                "export const {}Schema = z.enum([{}]);",
1033                                interface_name,
1034                                variant_names.join(", ")
1035                            )
1036                        };
1037                        schemas.push((format!("{}Schema", interface_name), schema));
1038                    }
1039                }
1040            }
1041        }
1042
1043        schemas
1044    }
1045
1046    fn typescript_type_to_zod_for_schema(
1047        &self,
1048        ts_type: &str,
1049        mode: SchemaMode,
1050        patch_schema_types: &HashSet<String>,
1051    ) -> String {
1052        typescript_type_to_zod_for_schema_static(ts_type, mode, patch_schema_types)
1053    }
1054
1055    fn patch_schema_type_names(&self) -> HashSet<String> {
1056        let mut names = HashSet::new();
1057        let resolved_name_map = self.build_resolved_type_name_map();
1058
1059        for section in &self.spec.sections {
1060            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1061                names.insert(self.section_interface_name(&section.name));
1062            }
1063
1064            for field in &section.fields {
1065                let Some(resolved) = &field.resolved_type else {
1066                    continue;
1067                };
1068
1069                if resolved.is_enum {
1070                    continue;
1071                }
1072
1073                names.insert(
1074                    self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map),
1075                );
1076            }
1077        }
1078
1079        names.insert("TokenMetadata".to_string());
1080        names
1081    }
1082
1083    fn generate_stack_definition(&self, state_view_key: &StateViewKeyDefinition) -> String {
1084        let stack_name = to_kebab_case(&self.entity_name);
1085        let entity_pascal = to_pascal_case(&self.entity_name);
1086        let export_name = format!(
1087            "{}_{}",
1088            self.entity_name.to_uppercase(),
1089            self.config.export_const_name
1090        );
1091
1092        let view_helpers = self.generate_view_helpers();
1093        let derived_views = self.generate_derived_view_entries();
1094        let schema_names = self.generate_schemas().names;
1095        let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
1096        for name in schema_names {
1097            unique_schemas.insert(name);
1098        }
1099        let schemas_block = if unique_schemas.is_empty() {
1100            String::new()
1101        } else {
1102            let schema_entries: Vec<String> = unique_schemas
1103                .iter()
1104                .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
1105                .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
1106                .collect();
1107            if schema_entries.is_empty() {
1108                String::new()
1109            } else {
1110                format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
1111            }
1112        };
1113
1114        let patch_schemas_block = format!(
1115            "\n  patchSchemas: {{\n    {entity}: {entity}PatchSchema,\n  }},",
1116            entity = entity_pascal
1117        );
1118
1119        // Generate URL line - either actual URL or placeholder comment
1120        let url_line = match &self.config.url {
1121            Some(url) => format!("  url: '{}',", url),
1122            None => "  url: '', // TODO: Set after first deployment or pass useArete(..., { url })"
1123                .to_string(),
1124        };
1125
1126        format!(
1127            r#"{}
1128
1129// ============================================================================
1130// Stack Definition
1131// ============================================================================
1132
1133/** Stack definition for {} */
1134export const {} = {{
1135  name: '{}',
1136{}
1137  views: {{
1138    {}: {{
1139      state: stateView<{}, {}>('{}/state', {}),
1140      list: listView<{}>('{}/list'),{}
1141    }},
1142  }},{}{}
1143}} as const;
1144
1145/** Type alias for the stack */
1146export type {}Stack = typeof {};
1147
1148/** Default export for convenience */
1149export default {};"#,
1150            view_helpers,
1151            entity_pascal,
1152            export_name,
1153            stack_name,
1154            url_line,
1155            self.entity_name,
1156            entity_pascal,
1157            state_view_key.object_type(),
1158            self.entity_name,
1159            state_view_key.fields_literal(),
1160            entity_pascal,
1161            self.entity_name,
1162            derived_views,
1163            schemas_block,
1164            patch_schemas_block,
1165            entity_pascal,
1166            export_name,
1167            export_name
1168        )
1169    }
1170
1171    fn generate_derived_view_entries(&self) -> String {
1172        let derived_views: Vec<&ViewDef> = self
1173            .views
1174            .iter()
1175            .filter(|v| {
1176                !v.id.ends_with("/state")
1177                    && !v.id.ends_with("/list")
1178                    && v.id.starts_with(&self.entity_name)
1179            })
1180            .collect();
1181
1182        if derived_views.is_empty() {
1183            return String::new();
1184        }
1185
1186        let entity_pascal = to_pascal_case(&self.entity_name);
1187        let mut entries = Vec::new();
1188
1189        for view in derived_views {
1190            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
1191
1192            entries.push(format!(
1193                "\n      {}: listView<{}>('{}'),",
1194                view_name, entity_pascal, view.id
1195            ));
1196        }
1197
1198        entries.join("")
1199    }
1200
1201    fn mapping_to_typescript_type(&self, mapping: &TypedFieldMapping<S>) -> String {
1202        // First, try to resolve from AST field mappings
1203        if let Some(field_info) = self.spec.field_mappings.get(&mapping.target_path) {
1204            let ts_type = self.field_type_info_to_typescript(field_info);
1205
1206            // If it's an Append strategy, wrap in array
1207            if matches!(mapping.population, PopulationStrategy::Append) {
1208                return if ts_type.ends_with("[]") {
1209                    ts_type
1210                } else {
1211                    format!("{}[]", ts_type)
1212                };
1213            }
1214
1215            return ts_type;
1216        }
1217
1218        // Fallback to legacy inference
1219        match &mapping.population {
1220            PopulationStrategy::Append => {
1221                // For arrays, try to infer the element type
1222                match &mapping.source {
1223                    MappingSource::AsEvent { .. } => "any[]".to_string(),
1224                    _ => "any[]".to_string(),
1225                }
1226            }
1227            _ => {
1228                // Infer type from source and field name
1229                let base_type = match &mapping.source {
1230                    MappingSource::FromSource { .. } => {
1231                        self.infer_type_from_field_name(&mapping.target_path)
1232                    }
1233                    MappingSource::Constant(value) => value_to_typescript_type(value),
1234                    MappingSource::AsEvent { .. } => "any".to_string(),
1235                    _ => "any".to_string(),
1236                };
1237
1238                // Apply transformations to type
1239                if let Some(transform) = &mapping.transform {
1240                    match transform {
1241                        Transformation::HexEncode | Transformation::HexDecode => {
1242                            "string".to_string()
1243                        }
1244                        Transformation::Base58Encode | Transformation::Base58Decode => {
1245                            "string".to_string()
1246                        }
1247                        Transformation::ToString => "string".to_string(),
1248                        Transformation::ToNumber => "number".to_string(),
1249                    }
1250                } else {
1251                    base_type
1252                }
1253            }
1254        }
1255    }
1256
1257    fn field_type_info_to_typescript(&self, field_info: &FieldTypeInfo) -> String {
1258        if let Some(resolved) = &field_info.resolved_type {
1259            let interface_name = self.resolved_type_to_interface_name(resolved);
1260
1261            let base_type = if resolved.is_event || (resolved.is_instruction && field_info.is_array)
1262            {
1263                format!("EventWrapper<{}>", interface_name)
1264            } else if resolved.is_account && self.is_capture_field(field_info) {
1265                format!("CaptureWrapper<{}>", interface_name)
1266            } else {
1267                interface_name
1268            };
1269
1270            let with_array = if field_info.is_array {
1271                format!("{}[]", base_type)
1272            } else {
1273                base_type
1274            };
1275
1276            return with_array;
1277        }
1278
1279        if let Some(inner_type) = &field_info.inner_type {
1280            if is_builtin_resolver_type(inner_type) {
1281                return inner_type.clone();
1282            }
1283        }
1284
1285        if let Some(ts_type) = typescript_integer_type(
1286            field_info.effective_integer_kind(),
1287            field_info
1288                .inner_type
1289                .as_deref()
1290                .or(Some(field_info.rust_type_name.as_str())),
1291        ) {
1292            return if field_info.is_array {
1293                format!("{}[]", ts_type)
1294            } else {
1295                ts_type.to_string()
1296            };
1297        }
1298
1299        // Arrays of scalar non-integer primitives (e.g. Vec<f64> display
1300        // fields) map to the corresponding primitive array instead of any[].
1301        if field_info.base_type == BaseType::Array && field_info.is_array {
1302            if let Some(element) = field_info
1303                .inner_type
1304                .as_deref()
1305                .and_then(typescript_scalar_array_element)
1306            {
1307                return format!("{}[]", element);
1308            }
1309        }
1310
1311        if field_info.base_type == BaseType::Any
1312            || (field_info.base_type == BaseType::Array
1313                && field_info.inner_type.as_deref() == Some("Value"))
1314        {
1315            if let Some(event_type) = self.find_event_interface_for_field(&field_info.field_name) {
1316                return if field_info.is_array {
1317                    format!("{}[]", event_type)
1318                } else {
1319                    event_type
1320                };
1321            }
1322        }
1323
1324        self.base_type_to_typescript(
1325            &field_info.base_type,
1326            field_info.effective_integer_kind(),
1327            field_info.is_array,
1328        )
1329    }
1330
1331    /// Find the generated event interface name for a given field
1332    fn find_event_interface_for_field(&self, field_name: &str) -> Option<String> {
1333        // Use the raw JSON handlers if available
1334        let handlers = self.handlers_json.as_ref()?.as_array()?;
1335
1336        // Look through handlers to find event mappings for this field
1337        for handler in handlers {
1338            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1339                for mapping in mappings {
1340                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1341                        // Check if this mapping targets our field (e.g., "events.created")
1342                        let target_parts: Vec<&str> = target_path.split('.').collect();
1343                        if let Some(target_field) = target_parts.last() {
1344                            if *target_field == field_name {
1345                                // Check if this is an event mapping
1346                                if let Some(source) = mapping.get("source") {
1347                                    if self.extract_event_data(source).is_some() {
1348                                        // Generate the interface name (e.g., "created" -> "CreatedEvent")
1349                                        return Some(format!(
1350                                            "{}Event",
1351                                            to_pascal_case(field_name)
1352                                        ));
1353                                    }
1354                                }
1355                            }
1356                        }
1357                    }
1358                }
1359            }
1360        }
1361        None
1362    }
1363
1364    /// Generate TypeScript interface name from resolved type
1365    fn resolved_type_to_interface_name(&self, resolved: &ResolvedStructType) -> String {
1366        self.build_resolved_type_name_map()
1367            .get(&resolved.type_name)
1368            .cloned()
1369            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1370    }
1371
1372    /// Generate nested interfaces for all resolved types in the AST
1373    fn generate_nested_interfaces(&self) -> Vec<String> {
1374        let mut interfaces = Vec::new();
1375        let mut generated_types = self.already_emitted_types.clone();
1376        let resolved_name_map = self.build_resolved_type_name_map();
1377
1378        // Collect all resolved types from all sections
1379        for section in &self.spec.sections {
1380            for field_info in &section.fields {
1381                if let Some(resolved) = &field_info.resolved_type {
1382                    let type_name =
1383                        self.resolved_type_to_interface_name_with_map(resolved, &resolved_name_map);
1384
1385                    // Only generate each type once
1386                    if generated_types.insert(type_name) {
1387                        let interface = self.generate_interface_for_resolved_type(resolved);
1388                        interfaces.push(interface);
1389                    }
1390                }
1391            }
1392        }
1393
1394        // Generate event interfaces from instruction handlers
1395        interfaces.extend(self.generate_event_interfaces(&mut generated_types));
1396
1397        // Also generate all enum types from the IDL (even if not directly referenced)
1398        if let Some(idl_value) = &self.idl {
1399            if let Some(types_array) = idl_value.get("types").and_then(|v| v.as_array()) {
1400                for type_def in types_array {
1401                    if let (Some(type_name), Some(type_obj)) = (
1402                        type_def.get("name").and_then(|v| v.as_str()),
1403                        type_def.get("type").and_then(|v| v.as_object()),
1404                    ) {
1405                        if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
1406                            // Only generate if not already generated
1407                            let interface_name = to_pascal_case(type_name);
1408                            if generated_types.insert(interface_name.clone()) {
1409                                if let Some(variants) =
1410                                    type_obj.get("variants").and_then(|v| v.as_array())
1411                                {
1412                                    let variant_names: Vec<String> = variants
1413                                        .iter()
1414                                        .filter_map(|v| {
1415                                            v.get("name")
1416                                                .and_then(|n| n.as_str())
1417                                                .map(|s| s.to_string())
1418                                        })
1419                                        .collect();
1420
1421                                    if !variant_names.is_empty() {
1422                                        let variant_strings: Vec<String> = variant_names
1423                                            .iter()
1424                                            .map(|v| format!("\"{}\"", to_pascal_case(v)))
1425                                            .collect();
1426
1427                                        let enum_type = format!(
1428                                            "export type {} = {};",
1429                                            interface_name,
1430                                            variant_strings.join(" | ")
1431                                        );
1432                                        interfaces.push(enum_type);
1433                                    }
1434                                }
1435                            }
1436                        }
1437                    }
1438                }
1439            }
1440        }
1441
1442        interfaces
1443    }
1444
1445    /// Generate TypeScript interfaces for event types from instruction handlers
1446    fn generate_event_interfaces(&self, generated_types: &mut HashSet<String>) -> Vec<String> {
1447        let mut interfaces = Vec::new();
1448
1449        // Use the raw JSON handlers if available
1450        let handlers = match &self.handlers_json {
1451            Some(h) => h.as_array(),
1452            None => return interfaces,
1453        };
1454
1455        let handlers_array = match handlers {
1456            Some(arr) => arr,
1457            None => return interfaces,
1458        };
1459
1460        // Look through handlers to find instruction-based event mappings
1461        for handler in handlers_array {
1462            // Check if this handler has event mappings
1463            if let Some(mappings) = handler.get("mappings").and_then(|m| m.as_array()) {
1464                for mapping in mappings {
1465                    if let Some(target_path) = mapping.get("target_path").and_then(|t| t.as_str()) {
1466                        // Check if the target is an event field (contains ".events." or starts with "events.")
1467                        if target_path.contains(".events.") || target_path.starts_with("events.") {
1468                            // Check if the source is AsEvent
1469                            if let Some(source) = mapping.get("source") {
1470                                if let Some(event_data) = self.extract_event_data(source) {
1471                                    // Extract instruction name from handler source
1472                                    if let Some(handler_source) = handler.get("source") {
1473                                        if let Some(instruction_name) =
1474                                            self.extract_instruction_name(handler_source)
1475                                        {
1476                                            // Generate interface name from target path (e.g., "events.created" -> "CreatedEvent")
1477                                            let event_field_name =
1478                                                target_path.split('.').next_back().unwrap_or("");
1479                                            let interface_name = format!(
1480                                                "{}Event",
1481                                                to_pascal_case(event_field_name)
1482                                            );
1483
1484                                            // Only generate once
1485                                            if generated_types.insert(interface_name.clone()) {
1486                                                if let Some(interface) = self
1487                                                    .generate_event_interface_from_idl(
1488                                                        &interface_name,
1489                                                        &instruction_name,
1490                                                        &event_data,
1491                                                    )
1492                                                {
1493                                                    interfaces.push(interface);
1494                                                }
1495                                            }
1496                                        }
1497                                    }
1498                                }
1499                            }
1500                        }
1501                    }
1502                }
1503            }
1504        }
1505
1506        interfaces
1507    }
1508
1509    /// Extract event field data from a mapping source
1510    fn extract_event_data(
1511        &self,
1512        source: &serde_json::Value,
1513    ) -> Option<Vec<(String, Option<String>)>> {
1514        if let Some(as_event) = source.get("AsEvent") {
1515            if let Some(fields) = as_event.get("fields").and_then(|f| f.as_array()) {
1516                let mut event_fields = Vec::new();
1517                for field in fields {
1518                    if let Some(from_source) = field.get("FromSource") {
1519                        if let Some(path) = from_source
1520                            .get("path")
1521                            .and_then(|p| p.get("segments"))
1522                            .and_then(|s| s.as_array())
1523                        {
1524                            // Get the last segment as the field name (e.g., ["data", "game_id"] -> "game_id")
1525                            if let Some(field_name) = path.last().and_then(|v| v.as_str()) {
1526                                let transform = from_source
1527                                    .get("transform")
1528                                    .and_then(|t| t.as_str())
1529                                    .map(|s| s.to_string());
1530                                event_fields.push((field_name.to_string(), transform));
1531                            }
1532                        }
1533                    }
1534                }
1535                return Some(event_fields);
1536            }
1537        }
1538        None
1539    }
1540
1541    /// Extract instruction name from handler source, returning the raw PascalCase name
1542    fn extract_instruction_name(&self, source: &serde_json::Value) -> Option<String> {
1543        if let Some(source_obj) = source.get("Source") {
1544            if let Some(type_name) = source_obj.get("type_name").and_then(|t| t.as_str()) {
1545                let instruction_part =
1546                    crate::event_type_helpers::strip_event_type_suffix(type_name);
1547                return Some(instruction_part.to_string());
1548            }
1549        }
1550        None
1551    }
1552
1553    /// Find an instruction in the IDL by name, handling different naming conventions.
1554    /// IDLs may use snake_case (pumpfun: "admin_set_creator") or camelCase (ore: "claimSol").
1555    /// The input name comes from Rust types which are PascalCase ("AdminSetCreator", "ClaimSol").
1556    fn find_instruction_in_idl<'a>(
1557        &self,
1558        instructions: &'a [serde_json::Value],
1559        rust_name: &str,
1560    ) -> Option<&'a serde_json::Value> {
1561        let normalized_search = normalize_for_comparison(rust_name);
1562
1563        for instruction in instructions {
1564            if let Some(idl_name) = instruction.get("name").and_then(|n| n.as_str()) {
1565                if normalize_for_comparison(idl_name) == normalized_search {
1566                    return Some(instruction);
1567                }
1568            }
1569        }
1570        None
1571    }
1572
1573    /// Generate a TypeScript interface for an event from IDL instruction data
1574    fn generate_event_interface_from_idl(
1575        &self,
1576        interface_name: &str,
1577        rust_instruction_name: &str,
1578        captured_fields: &[(String, Option<String>)],
1579    ) -> Option<String> {
1580        if captured_fields.is_empty() {
1581            return Some(format!("export interface {} {{}}", interface_name));
1582        }
1583
1584        let idl_value = self.idl.as_ref()?;
1585        let instructions = idl_value.get("instructions")?.as_array()?;
1586
1587        let instruction = self.find_instruction_in_idl(instructions, rust_instruction_name)?;
1588        let args = instruction.get("args")?.as_array()?;
1589
1590        let mut fields = Vec::new();
1591        for (field_name, transform) in captured_fields {
1592            for arg in args {
1593                if let Some(arg_name) = arg.get("name").and_then(|n| n.as_str()) {
1594                    if arg_name == field_name {
1595                        if let Some(arg_type) = arg.get("type") {
1596                            let ts_type =
1597                                self.idl_type_to_typescript(arg_type, transform.as_deref());
1598                            fields.push(TypeScriptField::patch(
1599                                field_name.to_string(),
1600                                ts_type,
1601                                false,
1602                            ));
1603                        }
1604                        break;
1605                    }
1606                }
1607            }
1608        }
1609
1610        if !fields.is_empty() {
1611            return Some(render_interface_from_ts_fields(
1612                interface_name,
1613                &fields,
1614                true,
1615            ));
1616        }
1617
1618        None
1619    }
1620
1621    /// Convert an IDL type (from JSON) to TypeScript, considering transforms
1622    fn idl_type_to_typescript(
1623        &self,
1624        idl_type: &serde_json::Value,
1625        transform: Option<&str>,
1626    ) -> String {
1627        #![allow(clippy::only_used_in_recursion)]
1628        // If there's a HexEncode transform, the result is always a string
1629        if transform == Some("HexEncode") {
1630            return "string".to_string();
1631        }
1632
1633        // Handle different IDL type formats
1634        if let Some(type_str) = idl_type.as_str() {
1635            return match type_str {
1636                "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
1637                "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => "number".to_string(),
1638                "f32" | "f64" => "number".to_string(),
1639                "bool" => "boolean".to_string(),
1640                "string" => "string".to_string(),
1641                "pubkey" | "publicKey" => "string".to_string(),
1642                "bytes" => "string".to_string(),
1643                _ => "any".to_string(),
1644            };
1645        }
1646
1647        // Handle complex types (option, vec, etc.)
1648        if let Some(type_obj) = idl_type.as_object() {
1649            if let Some(option_type) = type_obj.get("option") {
1650                let inner = self.idl_type_to_typescript(option_type, None);
1651                return format!("{} | null", inner);
1652            }
1653            if let Some(vec_type) = type_obj.get("vec") {
1654                let inner = self.idl_type_to_typescript(vec_type, None);
1655                return format!("{}[]", inner);
1656            }
1657        }
1658
1659        "any".to_string()
1660    }
1661
1662    /// Generate a TypeScript interface from a resolved struct type
1663    fn generate_interface_for_resolved_type(&self, resolved: &ResolvedStructType) -> String {
1664        let interface_name = self.resolved_type_to_interface_name(resolved);
1665
1666        // Handle enums as TypeScript union types
1667        if resolved.is_enum {
1668            let variants: Vec<String> = resolved
1669                .enum_variants
1670                .iter()
1671                .map(|v| format!("\"{}\"", to_pascal_case(v)))
1672                .collect();
1673
1674            return format!("export type {} = {};", interface_name, variants.join(" | "));
1675        }
1676
1677        render_interface_from_ts_fields(
1678            &interface_name,
1679            &self.resolved_fields_to_typescript_fields(&resolved.fields),
1680            true,
1681        )
1682    }
1683
1684    /// Convert a resolved field to TypeScript type
1685    fn resolved_field_to_typescript(&self, field: &ResolvedField) -> String {
1686        if let Some(ts_type) =
1687            typescript_integer_type(field.effective_integer_kind(), Some(&field.field_type))
1688        {
1689            return if field.is_array {
1690                format!("{}[]", ts_type)
1691            } else {
1692                ts_type.to_string()
1693            };
1694        }
1695        let base_ts =
1696            self.base_type_to_typescript(&field.base_type, field.effective_integer_kind(), false);
1697
1698        if field.is_array {
1699            format!("{}[]", base_ts)
1700        } else {
1701            base_ts
1702        }
1703    }
1704
1705    fn resolved_fields_to_typescript_fields(
1706        &self,
1707        fields: &[ResolvedField],
1708    ) -> Vec<TypeScriptField> {
1709        fields
1710            .iter()
1711            .map(|field| {
1712                TypeScriptField::from_names(
1713                    field.raw_field_name().to_string(),
1714                    field.canonical_field_name(),
1715                    self.resolved_field_to_typescript(field),
1716                    field.is_optional,
1717                    FieldPresence::Patch,
1718                )
1719            })
1720            .collect()
1721    }
1722
1723    /// Check if the spec has any event types
1724    fn has_event_types(&self) -> bool {
1725        for section in &self.spec.sections {
1726            for field_info in &section.fields {
1727                if let Some(resolved) = &field_info.resolved_type {
1728                    if resolved.is_event || (resolved.is_instruction && field_info.is_array) {
1729                        return true;
1730                    }
1731                }
1732            }
1733        }
1734        false
1735    }
1736
1737    fn has_capture_types(&self) -> bool {
1738        self.spec
1739            .sections
1740            .iter()
1741            .flat_map(|section| &section.fields)
1742            .any(|field| self.is_capture_field(field))
1743    }
1744
1745    fn should_emit_capture_wrapper(&self) -> bool {
1746        self.has_capture_types() && !self.already_emitted_types.contains("CaptureWrapper")
1747    }
1748
1749    fn is_capture_field(&self, field_info: &FieldTypeInfo) -> bool {
1750        let raw_name = field_info.raw_field_name();
1751        self.spec.handlers.iter().any(|handler| {
1752            handler.mappings.iter().any(|mapping| {
1753                matches!(&mapping.source, MappingSource::AsCapture { .. })
1754                    && (mapping.target_path == field_info.field_name
1755                        || mapping.target_path == raw_name)
1756            })
1757        })
1758    }
1759
1760    fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
1761        let mut reserved_names = self.already_emitted_types.clone();
1762        reserved_names.insert(to_pascal_case(&self.entity_name));
1763
1764        for section in &self.spec.sections {
1765            if !is_root_section(&section.name) && section.fields.iter().any(|field| field.emit) {
1766                reserved_names.insert(self.section_interface_name(&section.name));
1767            }
1768        }
1769
1770        let mut resolved_name_map = HashMap::new();
1771
1772        for section in &self.spec.sections {
1773            for field_info in &section.fields {
1774                if !field_info.emit {
1775                    continue;
1776                }
1777
1778                let Some(resolved) = &field_info.resolved_type else {
1779                    continue;
1780                };
1781
1782                if resolved_name_map.contains_key(&resolved.type_name) {
1783                    continue;
1784                }
1785
1786                let emitted_name = unique_resolved_type_name_ts(resolved, &mut reserved_names);
1787                resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
1788            }
1789        }
1790
1791        resolved_name_map
1792    }
1793
1794    fn resolved_type_to_interface_name_with_map(
1795        &self,
1796        resolved: &ResolvedStructType,
1797        resolved_name_map: &HashMap<String, String>,
1798    ) -> String {
1799        resolved_name_map
1800            .get(&resolved.type_name)
1801            .cloned()
1802            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1803    }
1804
1805    /// Generate the EventWrapper interface
1806    fn generate_event_wrapper_interface(&self) -> String {
1807        r#"/**
1808 * Wrapper for event data that includes context metadata.
1809 * Events are automatically wrapped in this structure at runtime.
1810 */
1811export interface EventWrapper<T> {
1812  /** Unix timestamp when the event was processed */
1813  timestamp: number;
1814  /** The event-specific data */
1815  data: T;
1816  /** Optional blockchain slot number */
1817  slot?: number;
1818  /** Optional transaction signature */
1819  signature?: string;
1820}"#
1821        .to_string()
1822    }
1823
1824    fn generate_capture_wrapper_interface(&self) -> String {
1825        r#"/**
1826 * Wrapper for account data captured with context metadata.
1827 */
1828export interface CaptureWrapper<T> {
1829  /** Unix timestamp when the account was captured */
1830  timestamp: number;
1831  /** Base58 account address */
1832  accountAddress: string;
1833  /** Captured account data */
1834  data: T;
1835  /** Optional blockchain slot number */
1836  slot?: number;
1837  /** Optional transaction signature */
1838  signature?: string;
1839}"#
1840        .to_string()
1841    }
1842
1843    fn infer_type_from_field_name(&self, field_name: &str) -> String {
1844        let lower_name = field_name.to_lowercase();
1845
1846        // Special case for event fields - these are typically Option<Value> and should be 'any'
1847        if lower_name.contains("events.") {
1848            // For fields in the events section, default to 'any' since they're typically Option<Value>
1849            return "any".to_string();
1850        }
1851
1852        // Common patterns for type inference
1853        if lower_name.contains("id")
1854            || lower_name.contains("count")
1855            || lower_name.contains("number")
1856            || lower_name.contains("timestamp")
1857            || lower_name.contains("time")
1858            || lower_name.contains("at")
1859            || lower_name.contains("volume")
1860            || lower_name.contains("amount")
1861            || lower_name.contains("ev")
1862            || lower_name.contains("fee")
1863            || lower_name.contains("payout")
1864            || lower_name.contains("distributed")
1865            || lower_name.contains("claimable")
1866            || lower_name.contains("total")
1867            || lower_name.contains("rate")
1868            || lower_name.contains("ratio")
1869            || lower_name.contains("current")
1870            || lower_name.contains("state")
1871        {
1872            "number".to_string()
1873        } else if lower_name.contains("status")
1874            || lower_name.contains("hash")
1875            || lower_name.contains("address")
1876            || lower_name.contains("key")
1877        {
1878            "string".to_string()
1879        } else {
1880            "any".to_string()
1881        }
1882    }
1883
1884    fn is_field_nullable(&self, mapping: &TypedFieldMapping<S>) -> bool {
1885        // Stream mappings produce patch-shaped objects, so this bool only captures
1886        // whether the field can be explicitly null in the payload.
1887        match &mapping.source {
1888            // Constants are typically non-optional
1889            MappingSource::Constant(_) => false,
1890            // Events are typically optional (Option<Value>)
1891            MappingSource::AsEvent { .. } => true,
1892            // For source fields, default to optional since most Rust fields are Option<T>
1893            MappingSource::FromSource { .. } => true,
1894            // Other cases default to optional
1895            _ => true,
1896        }
1897    }
1898
1899    /// Convert language-agnostic base types to TypeScript types
1900    fn base_type_to_typescript(
1901        &self,
1902        base_type: &BaseType,
1903        integer_kind: Option<IntegerKind>,
1904        is_array: bool,
1905    ) -> String {
1906        let base_ts_type = match base_type {
1907            BaseType::Integer => integer_kind
1908                .map(integer_kind_to_typescript)
1909                .unwrap_or("number"),
1910            BaseType::Float => "number",
1911            BaseType::String => "string",
1912            BaseType::Boolean => "boolean",
1913            BaseType::Timestamp => integer_kind
1914                .map(integer_kind_to_typescript)
1915                .unwrap_or("number"),
1916            BaseType::Binary => "string", // Base64 encoded strings
1917            BaseType::Pubkey => "string", // Solana public keys as Base58 strings
1918            BaseType::Array => "any[]",   // Default array type
1919            BaseType::Object => "Record<string, any>", // Generic object
1920            BaseType::Any => "any",
1921        };
1922
1923        if is_array && !matches!(base_type, BaseType::Array) {
1924            format!("{}[]", base_ts_type)
1925        } else {
1926            base_ts_type.to_string()
1927        }
1928    }
1929}
1930
1931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1932enum FieldPresence {
1933    Patch,
1934    Required,
1935}
1936
1937#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1938enum SchemaMode {
1939    Canonical,
1940    Patch,
1941}
1942
1943fn schema_constant_name(name: &str, mode: SchemaMode) -> String {
1944    match mode {
1945        SchemaMode::Canonical => format!("{}Schema", name),
1946        SchemaMode::Patch => format!("{}PatchSchema", name),
1947    }
1948}
1949
1950fn localize_section_field_names(
1951    section_name: &str,
1952    field_info: &FieldTypeInfo,
1953) -> (String, String) {
1954    let raw_name = field_info.raw_field_name();
1955    if is_root_section(section_name) {
1956        return (raw_name.to_string(), field_info.canonical_field_name());
1957    }
1958
1959    let prefix = format!("{}.", section_name);
1960    if let Some(local_raw_name) = raw_name.strip_prefix(&prefix) {
1961        return (local_raw_name.to_string(), to_camel_case(local_raw_name));
1962    }
1963
1964    (raw_name.to_string(), field_info.canonical_field_name())
1965}
1966
1967/// Represents a TypeScript field in an interface
1968#[derive(Debug, Clone)]
1969struct TypeScriptField {
1970    name: String,
1971    raw_name: String,
1972    ts_type: String,
1973    nullable: bool,
1974    presence: FieldPresence,
1975    zod_schema: Option<String>,
1976    #[allow(dead_code)]
1977    description: Option<String>,
1978}
1979
1980impl TypeScriptField {
1981    fn patch(raw_name: String, ts_type: String, nullable: bool) -> Self {
1982        Self::from_names(
1983            raw_name.clone(),
1984            to_camel_case(&raw_name),
1985            ts_type,
1986            nullable,
1987            FieldPresence::Patch,
1988        )
1989    }
1990
1991    fn required_with_schema(
1992        raw_name: String,
1993        canonical_name: String,
1994        ts_type: String,
1995        nullable: bool,
1996        zod_schema: String,
1997    ) -> Self {
1998        let mut field = Self::from_names(
1999            raw_name,
2000            canonical_name,
2001            ts_type,
2002            nullable,
2003            FieldPresence::Required,
2004        );
2005        field.zod_schema = Some(zod_schema);
2006        field
2007    }
2008
2009    fn from_names(
2010        raw_name: String,
2011        canonical_name: String,
2012        ts_type: String,
2013        nullable: bool,
2014        presence: FieldPresence,
2015    ) -> Self {
2016        Self {
2017            name: canonical_name,
2018            raw_name,
2019            ts_type,
2020            nullable,
2021            presence,
2022            zod_schema: None,
2023            description: None,
2024        }
2025    }
2026
2027    fn rendered_ts_type(&self) -> String {
2028        if self.nullable {
2029            format!("{} | null", self.ts_type)
2030        } else {
2031            self.ts_type.clone()
2032        }
2033    }
2034}
2035
2036#[derive(Debug, Clone)]
2037struct SchemaOutput {
2038    definitions: String,
2039    names: Vec<String>,
2040}
2041
2042#[derive(Debug, Default)]
2043struct IdlAccountArtifacts {
2044    code: String,
2045    schema_names: Vec<String>,
2046    type_names: HashSet<String>,
2047    account_type_names: BTreeMap<(String, String), String>,
2048}
2049
2050/// Convert serde_json::Value to TypeScript type string
2051fn value_to_typescript_type(value: &serde_json::Value) -> String {
2052    match value {
2053        serde_json::Value::Number(_) => "number".to_string(),
2054        serde_json::Value::String(_) => "string".to_string(),
2055        serde_json::Value::Bool(_) => "boolean".to_string(),
2056        serde_json::Value::Array(_) => "any[]".to_string(),
2057        serde_json::Value::Object(_) => "Record<string, any>".to_string(),
2058        serde_json::Value::Null => "null".to_string(),
2059    }
2060}
2061
2062fn extract_builtin_resolver_type_names(spec: &SerializableStreamSpec) -> HashSet<String> {
2063    let mut names = HashSet::new();
2064    let registry = crate::resolvers::builtin_resolver_registry();
2065    for resolver in registry.definitions() {
2066        let output_type = resolver.output_type();
2067        for section in &spec.sections {
2068            for field in &section.fields {
2069                if field.inner_type.as_deref() == Some(output_type) {
2070                    names.insert(output_type.to_string());
2071                }
2072            }
2073        }
2074    }
2075    names
2076}
2077
2078fn generate_idl_account_artifacts(
2079    idls: &[IdlSnapshot],
2080    reserved_type_names: &HashSet<String>,
2081) -> IdlAccountArtifacts {
2082    let mut used_type_names = reserved_type_names.clone();
2083    let mut emitted_type_names = HashSet::new();
2084    let mut seen_schema_names = HashSet::new();
2085    let mut interface_blocks = Vec::new();
2086    let mut schema_blocks = Vec::new();
2087    let mut schema_names = Vec::new();
2088    let mut account_type_names = BTreeMap::new();
2089
2090    for idl in idls {
2091        let program_key = to_camel_case(&idl.name);
2092        let program_prefix = to_pascal_case(&idl.name);
2093        let type_defs: BTreeMap<String, &IdlTypeDefSnapshot> = idl
2094            .types
2095            .iter()
2096            .map(|type_def| (type_def.name.clone(), type_def))
2097            .collect();
2098        let account_names: HashSet<String> = idl
2099            .accounts
2100            .iter()
2101            .map(|account| account.name.clone())
2102            .collect();
2103        let mut local_name_map = BTreeMap::new();
2104
2105        for account in &idl.accounts {
2106            let unique_name =
2107                unique_idl_type_name(&account.name, &program_prefix, &mut used_type_names);
2108            emitted_type_names.insert(unique_name.clone());
2109            local_name_map.insert(account.name.clone(), unique_name.clone());
2110            account_type_names.insert((program_key.clone(), account.name.clone()), unique_name);
2111        }
2112
2113        let mut required_defined_types = BTreeSet::new();
2114        for account in &idl.accounts {
2115            for field in resolve_idl_account_fields(account, &type_defs) {
2116                collect_required_defined_types(
2117                    &field.type_,
2118                    &type_defs,
2119                    &account_names,
2120                    &mut required_defined_types,
2121                );
2122            }
2123        }
2124
2125        for type_name in &required_defined_types {
2126            if local_name_map.contains_key(type_name) {
2127                continue;
2128            }
2129            let unique_name =
2130                unique_idl_type_name(type_name, &program_prefix, &mut used_type_names);
2131            emitted_type_names.insert(unique_name.clone());
2132            local_name_map.insert(type_name.clone(), unique_name);
2133        }
2134
2135        for type_name in &required_defined_types {
2136            if account_names.contains(type_name) {
2137                continue;
2138            }
2139            if let Some(type_def) = type_defs.get(type_name) {
2140                if let Some((interface_def, schema_name, schema_def)) =
2141                    generate_type_defs_from_idl_type(type_def, &local_name_map)
2142                {
2143                    interface_blocks.push(interface_def);
2144                    if seen_schema_names.insert(schema_name.clone()) {
2145                        schema_names.push(schema_name.clone());
2146                        schema_blocks.push(schema_def);
2147                    }
2148                }
2149            }
2150        }
2151
2152        for account in &idl.accounts {
2153            let Some(type_name) = local_name_map.get(&account.name) else {
2154                continue;
2155            };
2156            let account_fields = resolve_idl_account_fields(account, &type_defs);
2157            interface_blocks.push(generate_interface_from_idl_fields(
2158                type_name,
2159                account_fields,
2160                &local_name_map,
2161            ));
2162            let schema_name = format!("{}Schema", type_name);
2163            if seen_schema_names.insert(schema_name.clone()) {
2164                schema_names.push(schema_name.clone());
2165                schema_blocks.push(generate_schema_from_idl_fields(
2166                    type_name,
2167                    account_fields,
2168                    &local_name_map,
2169                ));
2170            }
2171        }
2172    }
2173
2174    let code = if interface_blocks.is_empty() && schema_blocks.is_empty() {
2175        String::new()
2176    } else if schema_blocks.is_empty() {
2177        interface_blocks.join("\n\n")
2178    } else if interface_blocks.is_empty() {
2179        schema_blocks.join("\n\n")
2180    } else {
2181        format!(
2182            "{}\n\n{}",
2183            interface_blocks.join("\n\n"),
2184            schema_blocks.join("\n\n")
2185        )
2186    };
2187
2188    IdlAccountArtifacts {
2189        code,
2190        schema_names,
2191        type_names: emitted_type_names,
2192        account_type_names,
2193    }
2194}
2195
2196fn resolve_idl_account_fields<'a>(
2197    account: &'a IdlAccountSnapshot,
2198    type_defs: &'a BTreeMap<String, &'a IdlTypeDefSnapshot>,
2199) -> &'a [IdlFieldSnapshot] {
2200    if !account.fields.is_empty() {
2201        return &account.fields;
2202    }
2203
2204    let Some(type_def) = type_defs.get(&account.name) else {
2205        return &account.fields;
2206    };
2207
2208    match &type_def.type_def {
2209        IdlTypeDefKindSnapshot::Struct { fields, .. } => fields,
2210        _ => &account.fields,
2211    }
2212}
2213
2214fn unique_idl_type_name(
2215    raw_name: &str,
2216    program_prefix: &str,
2217    used_type_names: &mut HashSet<String>,
2218) -> String {
2219    let base_name = to_pascal_case(raw_name);
2220    if used_type_names.insert(base_name.clone()) {
2221        return base_name;
2222    }
2223
2224    let prefixed = format!("{}{}", program_prefix, base_name);
2225    if used_type_names.insert(prefixed.clone()) {
2226        return prefixed;
2227    }
2228
2229    let mut index = 2;
2230    loop {
2231        let candidate = format!("{}{}", prefixed, index);
2232        if used_type_names.insert(candidate.clone()) {
2233            return candidate;
2234        }
2235        index += 1;
2236    }
2237}
2238
2239fn collect_required_defined_types(
2240    idl_type: &IdlTypeSnapshot,
2241    type_defs: &BTreeMap<String, &IdlTypeDefSnapshot>,
2242    account_names: &HashSet<String>,
2243    output: &mut BTreeSet<String>,
2244) {
2245    match idl_type {
2246        IdlTypeSnapshot::Simple(_) => {}
2247        IdlTypeSnapshot::Array(array_type) => {
2248            for element in &array_type.array {
2249                if let IdlArrayElementSnapshot::Type(inner) = element {
2250                    collect_required_defined_types(inner, type_defs, account_names, output);
2251                }
2252            }
2253        }
2254        IdlTypeSnapshot::Option(option_type) => {
2255            collect_required_defined_types(&option_type.option, type_defs, account_names, output);
2256        }
2257        IdlTypeSnapshot::Vec(vec_type) => {
2258            collect_required_defined_types(&vec_type.vec, type_defs, account_names, output);
2259        }
2260        IdlTypeSnapshot::HashMap(hash_map_type) => {
2261            collect_required_defined_types(
2262                &hash_map_type.hash_map.0,
2263                type_defs,
2264                account_names,
2265                output,
2266            );
2267            collect_required_defined_types(
2268                &hash_map_type.hash_map.1,
2269                type_defs,
2270                account_names,
2271                output,
2272            );
2273        }
2274        IdlTypeSnapshot::Defined(defined_type) => {
2275            let type_name = match &defined_type.defined {
2276                IdlDefinedInnerSnapshot::Named { name } => name,
2277                IdlDefinedInnerSnapshot::Simple(name) => name,
2278            };
2279
2280            if !output.insert(type_name.clone()) {
2281                return;
2282            }
2283
2284            if account_names.contains(type_name) {
2285                return;
2286            }
2287
2288            let Some(type_def) = type_defs.get(type_name) else {
2289                return;
2290            };
2291
2292            match &type_def.type_def {
2293                IdlTypeDefKindSnapshot::Struct { fields, .. } => {
2294                    for field in fields {
2295                        collect_required_defined_types(
2296                            &field.type_,
2297                            type_defs,
2298                            account_names,
2299                            output,
2300                        );
2301                    }
2302                }
2303                IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2304                    for field in fields {
2305                        collect_required_defined_types(field, type_defs, account_names, output);
2306                    }
2307                }
2308                IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2309                    for variant in variants {
2310                        for field in &variant.fields {
2311                            match field {
2312                                IdlEnumVariantFieldSnapshot::Named(named) => {
2313                                    collect_required_defined_types(
2314                                        &named.type_,
2315                                        type_defs,
2316                                        account_names,
2317                                        output,
2318                                    );
2319                                }
2320                                IdlEnumVariantFieldSnapshot::Tuple(tuple) => {
2321                                    collect_required_defined_types(
2322                                        tuple,
2323                                        type_defs,
2324                                        account_names,
2325                                        output,
2326                                    );
2327                                }
2328                            }
2329                        }
2330                    }
2331                }
2332            }
2333        }
2334    }
2335}
2336
2337fn generate_type_defs_from_idl_type(
2338    type_def: &IdlTypeDefSnapshot,
2339    local_name_map: &BTreeMap<String, String>,
2340) -> Option<(String, String, String)> {
2341    let type_name = local_name_map
2342        .get(&type_def.name)
2343        .cloned()
2344        .unwrap_or_else(|| to_pascal_case(&type_def.name));
2345    let schema_name = format!("{}Schema", type_name);
2346
2347    match &type_def.type_def {
2348        IdlTypeDefKindSnapshot::Struct { fields, .. } => Some((
2349            generate_interface_from_idl_fields(&type_name, fields, local_name_map),
2350            schema_name,
2351            generate_schema_from_idl_fields(&type_name, fields, local_name_map),
2352        )),
2353        IdlTypeDefKindSnapshot::TupleStruct { fields, .. } => {
2354            let interface = format!(
2355                "export type {} = [{}];",
2356                type_name,
2357                fields
2358                    .iter()
2359                    .map(|field| idl_snapshot_type_to_typescript(field, local_name_map))
2360                    .collect::<Vec<_>>()
2361                    .join(", ")
2362            );
2363            let schema = format!(
2364                "export const {} = z.tuple([{}]);",
2365                schema_name,
2366                fields
2367                    .iter()
2368                    .map(|field| idl_snapshot_type_to_zod(field, local_name_map))
2369                    .collect::<Vec<_>>()
2370                    .join(", ")
2371            );
2372            Some((interface, schema_name, schema))
2373        }
2374        IdlTypeDefKindSnapshot::Enum { variants, .. } => {
2375            let variant_names = variants
2376                .iter()
2377                .map(|variant| format!("\"{}\"", variant.name))
2378                .collect::<Vec<_>>();
2379            let interface = if variant_names.is_empty() {
2380                format!("export type {} = string;", type_name)
2381            } else {
2382                format!("export type {} = {};", type_name, variant_names.join(" | "))
2383            };
2384            let schema = if variant_names.is_empty() {
2385                format!("export const {} = z.string();", schema_name)
2386            } else {
2387                format!(
2388                    "export const {} = z.enum([{}]);",
2389                    schema_name,
2390                    variant_names.join(", ")
2391                )
2392            };
2393            Some((interface, schema_name, schema))
2394        }
2395    }
2396}
2397
2398fn generate_interface_from_idl_fields(
2399    name: &str,
2400    fields: &[IdlFieldSnapshot],
2401    local_name_map: &BTreeMap<String, String>,
2402) -> String {
2403    render_interface_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2404}
2405
2406fn generate_schema_from_idl_fields(
2407    name: &str,
2408    fields: &[IdlFieldSnapshot],
2409    local_name_map: &BTreeMap<String, String>,
2410) -> String {
2411    render_schema_from_ts_fields(name, &normalize_idl_fields(fields, local_name_map), true)
2412}
2413
2414fn normalize_idl_fields(
2415    fields: &[IdlFieldSnapshot],
2416    local_name_map: &BTreeMap<String, String>,
2417) -> Vec<TypeScriptField> {
2418    let mut canonical_names = BTreeMap::new();
2419    let mut normalized = Vec::with_capacity(fields.len());
2420
2421    for field in fields {
2422        let canonical_name = to_camel_case(&field.name);
2423        if let Some(existing_source) =
2424            canonical_names.insert(canonical_name.clone(), field.name.clone())
2425        {
2426            assert_eq!(
2427                existing_source, field.name,
2428                "IDL field normalization collision: '{}' and '{}' both normalize to '{}'",
2429                existing_source, field.name, canonical_name
2430            );
2431        }
2432
2433        let (normalized_type, nullable) = strip_nullable_idl_type(&field.type_);
2434
2435        normalized.push(TypeScriptField::required_with_schema(
2436            idl_field_wire_name(&field.name),
2437            canonical_name,
2438            idl_snapshot_type_to_typescript(normalized_type, local_name_map),
2439            nullable,
2440            idl_snapshot_type_to_zod(normalized_type, local_name_map),
2441        ));
2442    }
2443
2444    normalized
2445}
2446
2447fn idl_field_wire_name(field_name: &str) -> String {
2448    idl_to_snake_case(field_name)
2449}
2450
2451fn idl_snapshot_type_to_typescript(
2452    idl_type: &IdlTypeSnapshot,
2453    local_name_map: &BTreeMap<String, String>,
2454) -> String {
2455    match idl_type {
2456        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2457            "u64" | "u128" | "i64" | "i128" => "bigint".to_string(),
2458            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "number".to_string(),
2459            "bool" => "boolean".to_string(),
2460            "string" => "string".to_string(),
2461            "pubkey" | "publicKey" => "string".to_string(),
2462            "bytes" => "number[]".to_string(),
2463            _ => "any".to_string(),
2464        },
2465        IdlTypeSnapshot::Array(array_type) => {
2466            let (inner_ts, size) = match array_type.array.as_slice() {
2467                [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => (
2468                    Some(idl_snapshot_type_to_typescript(inner, local_name_map)),
2469                    Some(*size),
2470                ),
2471                [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2472                    (
2473                        Some(idl_snapshot_type_to_typescript(
2474                            &IdlTypeSnapshot::Simple(inner.clone()),
2475                            local_name_map,
2476                        )),
2477                        Some(*size),
2478                    )
2479                }
2480                _ => (None, None),
2481            };
2482            let inner_ts = inner_ts.unwrap_or_else(|| "any".to_string());
2483            if let Some(size) = size {
2484                format!(
2485                    "{}[]",
2486                    if size == 0 {
2487                        "never".to_string()
2488                    } else {
2489                        inner_ts
2490                    }
2491                )
2492            } else {
2493                format!("{}[]", inner_ts)
2494            }
2495        }
2496        IdlTypeSnapshot::Option(option_type) => {
2497            format!(
2498                "{} | null",
2499                idl_snapshot_type_to_typescript(&option_type.option, local_name_map)
2500            )
2501        }
2502        IdlTypeSnapshot::Vec(vec_type) => {
2503            format!(
2504                "{}[]",
2505                idl_snapshot_type_to_typescript(&vec_type.vec, local_name_map)
2506            )
2507        }
2508        IdlTypeSnapshot::HashMap(hash_map_type) => {
2509            format!(
2510                "Record<string, {}>",
2511                idl_snapshot_type_to_typescript(&hash_map_type.hash_map.1, local_name_map)
2512            )
2513        }
2514        IdlTypeSnapshot::Defined(defined_type) => {
2515            let type_name = match &defined_type.defined {
2516                IdlDefinedInnerSnapshot::Named { name } => name,
2517                IdlDefinedInnerSnapshot::Simple(name) => name,
2518            };
2519            local_name_map
2520                .get(type_name)
2521                .cloned()
2522                .unwrap_or_else(|| to_pascal_case(type_name))
2523        }
2524    }
2525}
2526
2527fn idl_snapshot_type_to_zod(
2528    idl_type: &IdlTypeSnapshot,
2529    local_name_map: &BTreeMap<String, String>,
2530) -> String {
2531    match idl_type {
2532        IdlTypeSnapshot::Simple(type_name) => match type_name.as_str() {
2533            "u64" | "u128" | "i64" | "i128" => bigint_zod(),
2534            "u8" | "u16" | "u32" | "i8" | "i16" | "i32" | "f32" | "f64" => "z.number()".to_string(),
2535            "bool" => "z.boolean()".to_string(),
2536            "string" => "z.string()".to_string(),
2537            "pubkey" | "publicKey" => "z.string()".to_string(),
2538            "bytes" => "z.array(z.number())".to_string(),
2539            _ => "z.any()".to_string(),
2540        },
2541        IdlTypeSnapshot::Array(array_type) => match array_type.array.as_slice() {
2542            [IdlArrayElementSnapshot::Type(inner), IdlArrayElementSnapshot::Size(size)] => {
2543                format!(
2544                    "z.array({}).length({})",
2545                    idl_snapshot_type_to_zod(inner, local_name_map),
2546                    size
2547                )
2548            }
2549            [IdlArrayElementSnapshot::TypeName(inner), IdlArrayElementSnapshot::Size(size)] => {
2550                format!(
2551                    "z.array({}).length({})",
2552                    idl_snapshot_type_to_zod(
2553                        &IdlTypeSnapshot::Simple(inner.clone()),
2554                        local_name_map
2555                    ),
2556                    size
2557                )
2558            }
2559            _ => "z.array(z.any())".to_string(),
2560        },
2561        IdlTypeSnapshot::Option(option_type) => {
2562            format!(
2563                "{}.nullable()",
2564                idl_snapshot_type_to_zod(&option_type.option, local_name_map)
2565            )
2566        }
2567        IdlTypeSnapshot::Vec(vec_type) => {
2568            format!(
2569                "z.array({})",
2570                idl_snapshot_type_to_zod(&vec_type.vec, local_name_map)
2571            )
2572        }
2573        IdlTypeSnapshot::HashMap(hash_map_type) => {
2574            format!(
2575                "z.record({})",
2576                idl_snapshot_type_to_zod(&hash_map_type.hash_map.1, local_name_map)
2577            )
2578        }
2579        IdlTypeSnapshot::Defined(defined_type) => {
2580            let type_name = match &defined_type.defined {
2581                IdlDefinedInnerSnapshot::Named { name } => name,
2582                IdlDefinedInnerSnapshot::Simple(name) => name,
2583            };
2584            let resolved_name = local_name_map
2585                .get(type_name)
2586                .cloned()
2587                .unwrap_or_else(|| to_pascal_case(type_name));
2588            format!("z.lazy(() => {}Schema)", resolved_name)
2589        }
2590    }
2591}
2592
2593fn typescript_integer_type_from_rust(rust_type: &str) -> Option<&'static str> {
2594    IntegerKind::from_rust_type(rust_type).map(integer_kind_to_typescript)
2595}
2596
2597fn typescript_integer_type(
2598    integer_kind: Option<IntegerKind>,
2599    rust_type: Option<&str>,
2600) -> Option<&'static str> {
2601    integer_kind
2602        .map(integer_kind_to_typescript)
2603        .or_else(|| rust_type.and_then(typescript_integer_type_from_rust))
2604}
2605
2606/// Map the element of a `Vec<T>` scalar array to its TypeScript primitive.
2607/// Accepts both stored forms of the inner type (`"Vec < f64 >"` and the bare
2608/// `"f64"`), returning `None` for non-scalar or non-array elements.
2609fn typescript_scalar_array_element(inner_type: &str) -> Option<&'static str> {
2610    let trimmed = inner_type.trim();
2611    let element = trimmed
2612        .strip_prefix("Vec <")
2613        .and_then(|rest| rest.strip_suffix('>'))
2614        .or_else(|| {
2615            trimmed
2616                .strip_prefix("Vec<")
2617                .and_then(|rest| rest.strip_suffix('>'))
2618        })
2619        .map(str::trim)
2620        .unwrap_or(trimmed);
2621    match element {
2622        "f32" | "f64" => Some("number"),
2623        "bool" => Some("boolean"),
2624        "String" | "&str" | "str" => Some("string"),
2625        _ => None,
2626    }
2627}
2628
2629fn integer_kind_to_typescript(integer_kind: IntegerKind) -> &'static str {
2630    if integer_kind.is_bigint() {
2631        "bigint"
2632    } else {
2633        "number"
2634    }
2635}
2636
2637fn strip_nullable_idl_type(mut idl_type: &IdlTypeSnapshot) -> (&IdlTypeSnapshot, bool) {
2638    let mut nullable = false;
2639    while let IdlTypeSnapshot::Option(option_type) = idl_type {
2640        nullable = true;
2641        idl_type = &option_type.option;
2642    }
2643    (idl_type, nullable)
2644}
2645
2646fn typescript_type_to_zod_static(ts_type: &str) -> String {
2647    let trimmed = ts_type.trim();
2648
2649    if let Some(inner) = trimmed.strip_suffix("[]") {
2650        return format!("z.array({})", typescript_type_to_zod_static(inner));
2651    }
2652
2653    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2654        if let Some(inner) = inner.strip_suffix('>') {
2655            return format!(
2656                "EventWrapperSchema({})",
2657                typescript_type_to_zod_static(inner)
2658            );
2659        }
2660    }
2661
2662    if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
2663        if let Some(inner) = inner.strip_suffix('>') {
2664            return format!(
2665                "CaptureWrapperSchema({})",
2666                typescript_type_to_zod_static(inner)
2667            );
2668        }
2669    }
2670
2671    match trimmed {
2672        "string" => "z.string()".to_string(),
2673        "number" => "z.number()".to_string(),
2674        "bigint" => bigint_zod(),
2675        "boolean" => "z.boolean()".to_string(),
2676        "any" => "z.any()".to_string(),
2677        "Record<string, any>" => "z.record(z.any())".to_string(),
2678        _ => format!("{}Schema", trimmed),
2679    }
2680}
2681
2682fn typescript_type_to_zod_for_schema_static(
2683    ts_type: &str,
2684    mode: SchemaMode,
2685    patch_schema_types: &HashSet<String>,
2686) -> String {
2687    let trimmed = ts_type.trim();
2688
2689    if let Some(inner) = trimmed.strip_suffix("[]") {
2690        return format!(
2691            "z.array({})",
2692            typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2693        );
2694    }
2695
2696    if let Some(inner) = trimmed.strip_prefix("EventWrapper<") {
2697        if let Some(inner) = inner.strip_suffix('>') {
2698            return format!(
2699                "EventWrapperSchema({})",
2700                typescript_type_to_zod_for_schema_static(inner, mode, patch_schema_types)
2701            );
2702        }
2703    }
2704
2705    if let Some(inner) = trimmed.strip_prefix("CaptureWrapper<") {
2706        if let Some(inner) = inner.strip_suffix('>') {
2707            return format!(
2708                "CaptureWrapperSchema({})",
2709                typescript_type_to_zod_for_schema_static(
2710                    inner,
2711                    SchemaMode::Canonical,
2712                    patch_schema_types,
2713                )
2714            );
2715        }
2716    }
2717
2718    match trimmed {
2719        "string" => "z.string()".to_string(),
2720        "number" => "z.number()".to_string(),
2721        "bigint" => bigint_zod(),
2722        "boolean" => "z.boolean()".to_string(),
2723        "any" => "z.any()".to_string(),
2724        "Record<string, any>" => "z.record(z.any())".to_string(),
2725        _ => {
2726            if mode == SchemaMode::Patch && patch_schema_types.contains(trimmed) {
2727                format!("{}PatchSchema", trimmed)
2728            } else {
2729                format!("{}Schema", trimmed)
2730            }
2731        }
2732    }
2733}
2734
2735fn render_interface_from_ts_fields(
2736    name: &str,
2737    fields: &[TypeScriptField],
2738    force_required: bool,
2739) -> String {
2740    if fields.is_empty() {
2741        return format!("export interface {} {{\n}}", name);
2742    }
2743
2744    let field_definitions = fields
2745        .iter()
2746        .map(|field| {
2747            let optional = if force_required || matches!(field.presence, FieldPresence::Required) {
2748                ""
2749            } else {
2750                "?"
2751            };
2752            format!(
2753                "  {}{}: {};",
2754                field.name,
2755                optional,
2756                field.rendered_ts_type()
2757            )
2758        })
2759        .collect::<Vec<_>>();
2760
2761    format!(
2762        "export interface {} {{\n{}\n}}",
2763        name,
2764        field_definitions.join("\n")
2765    )
2766}
2767
2768fn render_schema_from_ts_fields(
2769    name: &str,
2770    fields: &[TypeScriptField],
2771    force_required: bool,
2772) -> String {
2773    if fields.is_empty() {
2774        return format!("export const {}Schema = z.object({{}});", name);
2775    }
2776
2777    let field_definitions = fields
2778        .iter()
2779        .map(|field| {
2780            let base_schema = field
2781                .zod_schema
2782                .clone()
2783                .unwrap_or_else(|| typescript_type_to_zod_static(&field.ts_type));
2784            let with_nullable = if field.nullable {
2785                format!("{}.nullable()", base_schema)
2786            } else {
2787                base_schema
2788            };
2789            let schema = if force_required || matches!(field.presence, FieldPresence::Required) {
2790                with_nullable
2791            } else {
2792                format!("{}.optional()", with_nullable)
2793            };
2794            format!("  {}: {},", field.raw_name, schema)
2795        })
2796        .collect::<Vec<_>>();
2797
2798    let transform_fields = fields
2799        .iter()
2800        .map(|field| format!("  {}: value.{},", field.name, field.raw_name))
2801        .collect::<Vec<_>>();
2802
2803    format!(
2804        "export const {}Schema = z.object({{\n{}\n}}).transform((value) => ({{\n{}\n}}));",
2805        name,
2806        field_definitions.join("\n"),
2807        transform_fields.join("\n")
2808    )
2809}
2810
2811fn bigint_zod() -> String {
2812    "z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value))"
2813        .to_string()
2814}
2815
2816fn extract_idl_enum_type_names(idl: &serde_json::Value) -> HashSet<String> {
2817    let mut names = HashSet::new();
2818    if let Some(types_array) = idl.get("types").and_then(|v| v.as_array()) {
2819        for type_def in types_array {
2820            if let (Some(type_name), Some(type_obj)) = (
2821                type_def.get("name").and_then(|v| v.as_str()),
2822                type_def.get("type").and_then(|v| v.as_object()),
2823            ) {
2824                if type_obj.get("kind").and_then(|v| v.as_str()) == Some("enum") {
2825                    names.insert(to_pascal_case(type_name));
2826                }
2827            }
2828        }
2829    }
2830    names
2831}
2832
2833/// Extract enum type names that were actually emitted in the generated interfaces.
2834/// Looks for patterns like `export const DirectionKindSchema = z.enum([...])`
2835fn extract_emitted_enum_type_names(interfaces: &str, idl: Option<&IdlSnapshot>) -> HashSet<String> {
2836    let mut names = HashSet::new();
2837
2838    // Get all enum type names from the IDL
2839    let idl_enum_names: HashSet<String> = idl
2840        .and_then(|idl| serde_json::to_value(idl).ok())
2841        .map(|v| extract_idl_enum_type_names(&v))
2842        .unwrap_or_default();
2843
2844    // Look for emitted enum schemas in the interfaces
2845    // Pattern: export const DirectionKindSchema = z.enum([...]) or z.string() for empty variants
2846    for line in interfaces.lines() {
2847        if let Some(start) = line.find("export const ") {
2848            let end = line
2849                .find("Schema = z.enum")
2850                .or_else(|| line.find("Schema = z.string()"));
2851            if let Some(end) = end {
2852                let schema_name = line[start + 13..end].trim();
2853                // Check if this schema name corresponds to an IDL enum type
2854                if idl_enum_names.contains(schema_name) {
2855                    names.insert(schema_name.to_string());
2856                }
2857            }
2858        }
2859    }
2860
2861    names
2862}
2863
2864fn unique_resolved_type_name_ts(
2865    resolved: &ResolvedStructType,
2866    reserved_names: &mut HashSet<String>,
2867) -> String {
2868    let base_name = to_pascal_case(&resolved.type_name);
2869    if reserved_names.insert(base_name.clone()) {
2870        return base_name;
2871    }
2872
2873    let suffix = if resolved.is_account {
2874        "Account"
2875    } else if resolved.is_event {
2876        "Event"
2877    } else if resolved.is_instruction {
2878        "Instruction"
2879    } else {
2880        "Type"
2881    };
2882
2883    let preferred = format!("{}{}", base_name, suffix);
2884    if reserved_names.insert(preferred.clone()) {
2885        return preferred;
2886    }
2887
2888    let mut index = 2;
2889    loop {
2890        let candidate = format!("{}{}{}", base_name, suffix, index);
2891        if reserved_names.insert(candidate.clone()) {
2892            return candidate;
2893        }
2894        index += 1;
2895    }
2896}
2897
2898/// Convert snake_case to PascalCase
2899pub(crate) fn to_pascal_case(s: &str) -> String {
2900    s.split(['_', '-', '.'])
2901        .map(|word| {
2902            let mut chars = word.chars();
2903            match chars.next() {
2904                None => String::new(),
2905                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2906            }
2907        })
2908        .collect()
2909}
2910
2911fn to_camel_case(s: &str) -> String {
2912    let pascal = to_pascal_case(s);
2913    let mut chars = pascal.chars();
2914    match chars.next() {
2915        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
2916        None => pascal,
2917    }
2918}
2919
2920/// Normalize a name for case-insensitive comparison across naming conventions.
2921/// Removes underscores and converts to lowercase: "claim_sol", "claimSol", "ClaimSol" all become "claimsol"
2922fn normalize_for_comparison(s: &str) -> String {
2923    s.chars()
2924        .filter(|c| *c != '_')
2925        .flat_map(|c| c.to_lowercase())
2926        .collect()
2927}
2928
2929fn is_root_section(name: &str) -> bool {
2930    name.eq_ignore_ascii_case("root")
2931}
2932
2933fn state_view_key_definition(
2934    entity_name: &str,
2935    identity: &IdentitySpec,
2936    field_mappings: &BTreeMap<String, FieldTypeInfo>,
2937    sections: &[EntitySection],
2938) -> Result<StateViewKeyDefinition, String> {
2939    let mut seen = HashSet::new();
2940    let distinct_keys: Vec<&str> = identity
2941        .primary_keys
2942        .iter()
2943        .map(String::as_str)
2944        .filter(|key| seen.insert(*key))
2945        .collect();
2946
2947    if distinct_keys.len() > 1 {
2948        return Err(format!(
2949            "TypeScript SDK generation does not support composite state keys for entity '{}': distinct identity.primary_keys are [{}]",
2950            entity_name,
2951            distinct_keys.join(", ")
2952        ));
2953    }
2954
2955    let key_path = distinct_keys.first().copied().ok_or_else(|| {
2956        format!(
2957            "TypeScript SDK generation requires a primary key for entity '{}'",
2958            entity_name
2959        )
2960    })?;
2961    let key_leaf = key_path.rsplit('.').next().unwrap_or(key_path);
2962    let field_info = field_mappings.get(key_path).or_else(|| {
2963        sections.iter().find_map(|section| {
2964            section.fields.iter().find(|field| {
2965                field.raw_field_name() == key_path
2966                    || field.raw_field_name() == key_leaf
2967                    || field.field_name == key_path
2968                    || field.field_name == key_leaf
2969            })
2970        })
2971    });
2972
2973    let field_name = field_info
2974        .map(FieldTypeInfo::canonical_field_name)
2975        .unwrap_or_else(|| to_camel_case(key_leaf));
2976    let typescript_type = match field_info {
2977        Some(field) if field.is_array => {
2978            return Err(format!(
2979                "TypeScript SDK generation does not support array state key '{}' for entity '{}'",
2980                key_path, entity_name
2981            ));
2982        }
2983        Some(field) => match field.base_type {
2984            BaseType::String | BaseType::Binary | BaseType::Pubkey => "string".to_string(),
2985            BaseType::Integer | BaseType::Timestamp => field
2986                .effective_integer_kind()
2987                .map(integer_kind_to_typescript)
2988                .unwrap_or("number")
2989                .to_string(),
2990            _ => {
2991                return Err(format!(
2992                    "TypeScript SDK generation does not support state key '{}' with type '{}' for entity '{}'",
2993                    key_path, field.rust_type_name, entity_name
2994                ));
2995            }
2996        },
2997        // Legacy ASTs may omit field metadata; retain their existing string wire key.
2998        None => "string".to_string(),
2999    };
3000
3001    Ok(StateViewKeyDefinition {
3002        field_name,
3003        typescript_type,
3004    })
3005}
3006
3007fn is_builtin_resolver_type(type_name: &str) -> bool {
3008    crate::resolvers::is_resolver_output_type(type_name)
3009}
3010
3011/// Convert PascalCase/camelCase to kebab-case
3012fn to_kebab_case(s: &str) -> String {
3013    let mut result = String::new();
3014
3015    for ch in s.chars() {
3016        if ch.is_uppercase() && !result.is_empty() {
3017            result.push('-');
3018        }
3019        result.push(ch.to_lowercase().next().unwrap());
3020    }
3021
3022    result
3023}
3024
3025/// CLI-friendly function to generate TypeScript from a spec function
3026/// This will be used by the CLI tool to generate TypeScript from discovered specs
3027pub fn generate_typescript_from_spec_fn<F, S>(
3028    spec_fn: F,
3029    entity_name: String,
3030    config: Option<TypeScriptConfig>,
3031) -> Result<TypeScriptOutput, String>
3032where
3033    F: Fn() -> TypedStreamSpec<S>,
3034{
3035    let spec = spec_fn();
3036    let compiler =
3037        TypeScriptCompiler::new(spec, entity_name).with_config(config.unwrap_or_default());
3038
3039    compiler.try_compile()
3040}
3041
3042/// Write TypeScript output to a file
3043pub fn write_typescript_to_file(
3044    output: &TypeScriptOutput,
3045    path: &std::path::Path,
3046) -> Result<(), std::io::Error> {
3047    std::fs::write(path, output.full_file())
3048}
3049
3050/// Generate TypeScript from a SerializableStreamSpec (for CLI use)
3051/// This allows the CLI to compile TypeScript without needing the typed spec
3052pub fn compile_serializable_spec(
3053    spec: SerializableStreamSpec,
3054    entity_name: String,
3055    config: Option<TypeScriptConfig>,
3056) -> Result<TypeScriptOutput, String> {
3057    compile_serializable_spec_with_emitted(spec, entity_name, config, HashSet::new())
3058}
3059
3060fn compile_serializable_spec_with_emitted(
3061    spec: SerializableStreamSpec,
3062    entity_name: String,
3063    config: Option<TypeScriptConfig>,
3064    already_emitted_types: HashSet<String>,
3065) -> Result<TypeScriptOutput, String> {
3066    let idl = spec
3067        .idl
3068        .as_ref()
3069        .and_then(|idl_snapshot| serde_json::to_value(idl_snapshot).ok());
3070
3071    let handlers = serde_json::to_value(&spec.handlers).ok();
3072    let views = spec.views.clone();
3073
3074    let typed_spec: TypedStreamSpec<()> = TypedStreamSpec::from_serializable(spec);
3075
3076    let compiler = TypeScriptCompiler::new(typed_spec, entity_name)
3077        .with_idl(idl)
3078        .with_handlers_json(handlers)
3079        .with_views(views)
3080        .with_config(config.unwrap_or_default())
3081        .with_already_emitted_types(already_emitted_types);
3082
3083    compiler.try_compile()
3084}
3085
3086#[derive(Debug, Clone, PartialEq, Eq)]
3087pub struct TypeScriptProgramDefinitionMetadata {
3088    pub program_id: String,
3089    pub sdk_definition_hash: Option<String>,
3090    pub program_spec_hash: String,
3091    pub idl_content_hash: String,
3092    pub normalized_idl_hash: String,
3093}
3094
3095#[derive(Debug, Clone, PartialEq, Eq)]
3096pub struct TypeScriptProgramReleaseReference {
3097    pub program_release_hash: String,
3098    pub program_spec_hash: String,
3099}
3100
3101#[derive(Debug, Clone, PartialEq)]
3102pub struct TypeScriptProgramReadBinding {
3103    pub endpoint: String,
3104    pub program_read_binding_id: String,
3105    pub auth: serde_json::Value,
3106}
3107
3108#[derive(Debug, Clone, PartialEq)]
3109pub enum TypeScriptProgramReadTransport {
3110    LocalHttp,
3111    HostedBinding(TypeScriptProgramReadBinding),
3112}
3113
3114#[derive(Debug, Clone, PartialEq)]
3115pub struct TypeScriptProgramConfig {
3116    pub definition: TypeScriptProgramDefinitionMetadata,
3117    pub release: TypeScriptProgramReleaseReference,
3118    pub transport: TypeScriptProgramReadTransport,
3119}
3120
3121impl From<&arete_hash::OssProgramIdentityV1> for TypeScriptProgramConfig {
3122    fn from(identity: &arete_hash::OssProgramIdentityV1) -> Self {
3123        Self {
3124            definition: TypeScriptProgramDefinitionMetadata {
3125                program_id: identity.program_spec.program_id.clone(),
3126                sdk_definition_hash: None,
3127                program_spec_hash: identity.program_spec_hash.to_string(),
3128                idl_content_hash: identity.program_spec.idl_content_hash.to_string(),
3129                normalized_idl_hash: identity.program_spec.normalized_idl_hash.to_string(),
3130            },
3131            release: TypeScriptProgramReleaseReference {
3132                program_release_hash: identity.release_hash.to_string(),
3133                program_spec_hash: identity.program_spec_hash.to_string(),
3134            },
3135            transport: TypeScriptProgramReadTransport::LocalHttp,
3136        }
3137    }
3138}
3139
3140#[derive(Debug, Clone)]
3141pub struct TypeScriptStackConfig {
3142    pub package_name: String,
3143    pub generate_helpers: bool,
3144    pub export_const_name: String,
3145    pub websocket_url: Option<String>,
3146    pub http_url: Option<String>,
3147    pub extension_import: Option<String>,
3148    /// Hosted metadata in exact AST program order. Local generation derives this from
3149    /// `SerializableStackSpec::program_specs` instead.
3150    pub programs: Option<Vec<TypeScriptProgramConfig>>,
3151}
3152
3153impl Default for TypeScriptStackConfig {
3154    fn default() -> Self {
3155        Self {
3156            package_name: "@usearete/react".to_string(),
3157            generate_helpers: true,
3158            export_const_name: "STACK".to_string(),
3159            websocket_url: None,
3160            http_url: None,
3161            extension_import: None,
3162            programs: None,
3163        }
3164    }
3165}
3166
3167#[derive(Debug, Clone)]
3168pub struct TypeScriptStackOutput {
3169    pub interfaces: String,
3170    pub stack_definition: String,
3171    pub imports: String,
3172    /// Non-fatal codegen warnings (skipped instructions, PDAs degraded to
3173    /// user-provided accounts). Callers should surface these to the user.
3174    pub warnings: Vec<String>,
3175    /// Structured PDA degradations for summary reporting.
3176    pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3177}
3178
3179#[derive(Debug, Clone, Default)]
3180pub struct TypeScriptLiveEndpoints {
3181    pub websocket_url: Option<String>,
3182    pub http_url: Option<String>,
3183}
3184
3185#[derive(Debug, Clone, Default)]
3186pub struct TypeScriptCompositionConfig {
3187    pub stack: TypeScriptStackConfig,
3188    pub live_endpoints: BTreeMap<String, TypeScriptLiveEndpoints>,
3189    pub live_module_imports: BTreeMap<String, String>,
3190    pub program_module_imports: BTreeMap<String, String>,
3191}
3192
3193#[derive(Debug, Clone)]
3194pub struct TypeScriptAliasedStackOutput {
3195    pub alias: String,
3196    pub module_name: String,
3197    pub output: TypeScriptStackOutput,
3198}
3199
3200#[derive(Debug, Clone)]
3201pub struct TypeScriptProgramCollectionOutput {
3202    pub module_name: String,
3203    pub output: TypeScriptStackOutput,
3204    pub members: Vec<(String, String)>,
3205}
3206
3207#[derive(Debug, Clone)]
3208pub struct TypeScriptCompositionOutput {
3209    pub name: String,
3210    pub live_stacks: Vec<TypeScriptAliasedStackOutput>,
3211    pub program_collection: Option<TypeScriptProgramCollectionOutput>,
3212    pub session_definition: String,
3213    pub warnings: Vec<String>,
3214    pub pda_degradations: Vec<crate::typescript_instructions::PdaDegradation>,
3215}
3216
3217impl TypeScriptStackOutput {
3218    pub fn full_file(&self) -> String {
3219        let mut parts = Vec::new();
3220        if !self.imports.is_empty() {
3221            parts.push(self.imports.as_str());
3222        }
3223        if !self.interfaces.is_empty() {
3224            parts.push(self.interfaces.as_str());
3225        }
3226        if !self.stack_definition.is_empty() {
3227            parts.push(self.stack_definition.as_str());
3228        }
3229        parts.join("\n\n")
3230    }
3231}
3232
3233fn resolve_program_configs(
3234    stack_spec: &SerializableStackSpec,
3235    configured: Option<&[TypeScriptProgramConfig]>,
3236    allow_view_only: bool,
3237) -> Result<Vec<TypeScriptProgramConfig>, String> {
3238    if stack_spec.idls.is_empty() {
3239        if stack_spec.program_specs.is_empty()
3240            && configured.is_none_or(|programs| programs.is_empty())
3241        {
3242            return Ok(Vec::new());
3243        }
3244        return Err(format!(
3245            "Stack '{}' has program metadata but no ordered IDL list",
3246            stack_spec.stack_name
3247        ));
3248    }
3249
3250    if stack_spec.program_specs.is_empty() {
3251        let view_only = stack_spec.program_ids.is_empty()
3252            && stack_spec.instructions.is_empty()
3253            && stack_spec.pdas.is_empty()
3254            && stack_spec
3255                .idls
3256                .iter()
3257                .all(|idl| idl.accounts.is_empty() && idl.instructions.is_empty());
3258        if allow_view_only && view_only && configured.is_none_or(|programs| programs.is_empty()) {
3259            return Ok(Vec::new());
3260        }
3261        return Err(format!(
3262            "Stack '{}' uses program SDK/account definitions but its AST has no exact public ProgramSpecV1 values. Regenerate the .stack.json with the current arete-macros before generating an SDK.",
3263            stack_spec.stack_name
3264        ));
3265    }
3266
3267    let expected = stack_spec.idls.len();
3268    if stack_spec.program_ids.len() != expected || stack_spec.program_specs.len() != expected {
3269        return Err(format!(
3270            "Stack '{}' program metadata is not aligned: program_ids={}, idls={}, program_specs={}. Regenerate the .stack.json with the current arete-macros.",
3271            stack_spec.stack_name,
3272            stack_spec.program_ids.len(),
3273            stack_spec.idls.len(),
3274            stack_spec.program_specs.len(),
3275        ));
3276    }
3277
3278    let mut exact_definitions = Vec::with_capacity(expected);
3279    let mut program_keys = BTreeSet::new();
3280    for (index, ((program_id, idl), program_spec)) in stack_spec
3281        .program_ids
3282        .iter()
3283        .zip(&stack_spec.idls)
3284        .zip(&stack_spec.program_specs)
3285        .enumerate()
3286    {
3287        program_spec.validate().map_err(|error| {
3288            format!(
3289                "Stack '{}' program_specs[{index}] is invalid: {error}",
3290                stack_spec.stack_name
3291            )
3292        })?;
3293        if program_id != &program_spec.program_id {
3294            return Err(format!(
3295                "Stack '{}' program ID mismatch at index {index}: program_ids has '{}', ProgramSpecV1 has '{}'",
3296                stack_spec.stack_name, program_id, program_spec.program_id
3297            ));
3298        }
3299        if idl.program_id.as_deref() != Some(program_spec.program_id.as_str()) {
3300            return Err(format!(
3301                "Stack '{}' IDL program ID mismatch at index {index}: expected '{}'",
3302                stack_spec.stack_name, program_spec.program_id
3303            ));
3304        }
3305        if idl.name != program_spec.idl_snapshot.snapshot.name {
3306            return Err(format!(
3307                "Stack '{}' IDL/ProgramSpec name mismatch at index {index}: '{}' != '{}'",
3308                stack_spec.stack_name, idl.name, program_spec.idl_snapshot.snapshot.name
3309            ));
3310        }
3311        let program_key = to_camel_case(&idl.name);
3312        if !program_keys.insert(program_key.clone()) {
3313            return Err(format!(
3314                "Stack '{}' has an ambiguous duplicate generated program key '{}'",
3315                stack_spec.stack_name, program_key
3316            ));
3317        }
3318        exact_definitions.push(TypeScriptProgramDefinitionMetadata {
3319            program_id: program_spec.program_id.clone(),
3320            sdk_definition_hash: None,
3321            program_spec_hash: program_spec
3322                .hash()
3323                .map_err(|error| {
3324                    format!(
3325                        "Stack '{}' could not hash ProgramSpecV1 at index {index}: {error}",
3326                        stack_spec.stack_name
3327                    )
3328                })?
3329                .to_string(),
3330            idl_content_hash: program_spec.idl_content_hash.to_string(),
3331            normalized_idl_hash: program_spec.normalized_idl_hash.to_string(),
3332        });
3333    }
3334
3335    let Some(configured) = configured else {
3336        return stack_spec
3337            .program_specs
3338            .iter()
3339            .enumerate()
3340            .map(|(index, program_spec)| {
3341                arete_hash::OssProgramIdentityV1::new(program_spec.clone())
3342                    .map(|identity| TypeScriptProgramConfig::from(&identity))
3343                    .map_err(|error| {
3344                        format!(
3345                            "Stack '{}' could not derive the OSS release for program index {index}: {error}",
3346                            stack_spec.stack_name
3347                        )
3348                    })
3349            })
3350            .collect();
3351    };
3352    if configured.len() != expected {
3353        return Err(format!(
3354            "Stack '{}' hosted descriptor count mismatch: expected {expected}, received {}",
3355            stack_spec.stack_name,
3356            configured.len()
3357        ));
3358    }
3359
3360    for (index, (hosted, exact)) in configured.iter().zip(&exact_definitions).enumerate() {
3361        if hosted.definition.program_id != exact.program_id {
3362            return Err(format!(
3363                "Stack '{}' hosted descriptor program ID mismatch at index {index}: expected '{}', received '{}'",
3364                stack_spec.stack_name,
3365                exact.program_id,
3366                hosted.definition.program_id
3367            ));
3368        }
3369        if hosted.definition.program_spec_hash != exact.program_spec_hash {
3370            return Err(format!(
3371                "Stack '{}' hosted descriptor programSpecHash mismatch at index {index}: expected '{}', received '{}'",
3372                stack_spec.stack_name,
3373                exact.program_spec_hash,
3374                hosted.definition.program_spec_hash
3375            ));
3376        }
3377        if hosted.definition.idl_content_hash != exact.idl_content_hash
3378            || hosted.definition.normalized_idl_hash != exact.normalized_idl_hash
3379        {
3380            return Err(format!(
3381                "Stack '{}' hosted descriptor definition hashes mismatch at index {index}",
3382                stack_spec.stack_name
3383            ));
3384        }
3385        if hosted.release.program_spec_hash != hosted.definition.program_spec_hash {
3386            return Err(format!(
3387                "Stack '{}' hosted descriptor release programSpecHash mismatch at index {index}",
3388                stack_spec.stack_name
3389            ));
3390        }
3391        if let TypeScriptProgramReadTransport::HostedBinding(binding) = &hosted.transport {
3392            let target_kind = binding
3393                .auth
3394                .get("targetKind")
3395                .and_then(serde_json::Value::as_str);
3396            let session_endpoint = binding
3397                .auth
3398                .get("sessionEndpoint")
3399                .and_then(serde_json::Value::as_str);
3400            if binding.endpoint.trim().is_empty()
3401                || binding.program_read_binding_id.trim().is_empty()
3402                || target_kind != Some("program-read-binding")
3403                || session_endpoint.is_none_or(|value| value.trim().is_empty())
3404            {
3405                return Err(format!(
3406                    "Stack '{}' hosted descriptor binding is incomplete at index {index}",
3407                    stack_spec.stack_name
3408                ));
3409            }
3410        }
3411    }
3412
3413    Ok(configured.to_vec())
3414}
3415
3416/// Compile a full SerializableStackSpec (multi-entity) into a single TypeScript file.
3417///
3418/// Generates:
3419/// - Interfaces for ALL entities (OreRound, OreTreasury, OreMiner, etc.)
3420/// - A single unified stack definition with nested views per entity
3421/// - View helpers (stateView, listView)
3422pub fn compile_stack_spec(
3423    stack_spec: SerializableStackSpec,
3424    config: Option<TypeScriptStackConfig>,
3425) -> Result<TypeScriptStackOutput, String> {
3426    compile_stack_spec_with_view_selection(stack_spec, config, false)
3427}
3428
3429fn compile_stack_spec_with_view_selection(
3430    stack_spec: SerializableStackSpec,
3431    config: Option<TypeScriptStackConfig>,
3432    exact_views: bool,
3433) -> Result<TypeScriptStackOutput, String> {
3434    let config = config.unwrap_or_default();
3435    let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), true)?;
3436    let stack_name = &stack_spec.stack_name;
3437    let stack_kebab = to_kebab_case(stack_name);
3438
3439    // 1. Compile each entity's interfaces using existing per-entity compiler
3440    let mut all_interfaces = Vec::new();
3441    let mut entity_names = Vec::new();
3442    let mut schema_names: Vec<String> = Vec::new();
3443    let mut emitted_types: HashSet<String> = HashSet::new();
3444
3445    for entity_spec in &stack_spec.entities {
3446        let mut spec = entity_spec.clone();
3447        // Inject stack-level IDL if entity doesn't have its own
3448        if spec.idl.is_none() {
3449            spec.idl = stack_spec.idls.first().cloned();
3450        }
3451        let entity_name = spec.state_name.clone();
3452        entity_names.push(entity_name.clone());
3453
3454        let per_entity_config = TypeScriptConfig {
3455            package_name: config.package_name.clone(),
3456            generate_helpers: false,
3457            interface_prefix: String::new(),
3458            export_const_name: config.export_const_name.clone(),
3459            url: config.websocket_url.clone(),
3460        };
3461
3462        // Collect builtin type names before spec is consumed
3463        let builtin_type_names = extract_builtin_resolver_type_names(&spec);
3464        // Clone IDL before spec is moved so we can check which enums were emitted
3465        let idl_for_check = spec.idl.clone();
3466
3467        let output = compile_serializable_spec_with_emitted(
3468            spec,
3469            entity_name,
3470            Some(per_entity_config),
3471            emitted_types.clone(),
3472        )?;
3473
3474        // Track shared types for cross-entity dedup
3475        // Only track enum types that were actually emitted (found in output.interfaces)
3476        let emitted_enum_names =
3477            extract_emitted_enum_type_names(&output.interfaces, idl_for_check.as_ref());
3478        emitted_types.extend(emitted_enum_names);
3479        emitted_types.extend(builtin_type_names);
3480        if output
3481            .interfaces
3482            .contains("export interface CaptureWrapper<T>")
3483        {
3484            emitted_types.insert("CaptureWrapper".to_string());
3485        }
3486
3487        // Only take the interfaces part (not the stack_definition — we generate our own)
3488        if !output.interfaces.is_empty() {
3489            all_interfaces.push(output.interfaces);
3490        }
3491
3492        schema_names.extend(output.schema_names);
3493    }
3494
3495    let mut interfaces = all_interfaces.join("\n\n");
3496
3497    // 2. Generate instruction-construction handlers from the stack spec.
3498    // Program errors live once at the stack level (in the IDL snapshots) and
3499    // are scoped per program by the instruction codegen. Entity interface
3500    // names are reserved so defined-type interfaces cannot collide with them.
3501    let mut reserved_type_names: std::collections::HashSet<String> =
3502        std::collections::HashSet::new();
3503    for line in interfaces.lines() {
3504        for prefix in ["export interface ", "export type "] {
3505            if let Some(rest) = line.strip_prefix(prefix) {
3506                let name: String = rest
3507                    .chars()
3508                    .take_while(|c| c.is_alphanumeric() || *c == '_')
3509                    .collect();
3510                if !name.is_empty() {
3511                    reserved_type_names.insert(name);
3512                }
3513            }
3514        }
3515    }
3516    let idl_account_artifacts =
3517        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
3518    for type_name in &idl_account_artifacts.type_names {
3519        reserved_type_names.insert(type_name.clone());
3520    }
3521    if !idl_account_artifacts.code.is_empty() {
3522        if interfaces.is_empty() {
3523            interfaces = idl_account_artifacts.code.clone();
3524        } else {
3525            interfaces = format!("{}\n\n{}", interfaces, idl_account_artifacts.code);
3526        }
3527    }
3528    schema_names.extend(idl_account_artifacts.schema_names.clone());
3529
3530    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
3531        stack_name,
3532        &stack_spec.instructions,
3533        &stack_spec.idls,
3534        &stack_spec.pdas,
3535        &stack_spec.program_ids,
3536        &reserved_type_names,
3537    );
3538    if !instructions_codegen.code.is_empty() {
3539        if interfaces.is_empty() {
3540            interfaces = instructions_codegen.code.clone();
3541        } else {
3542            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
3543        }
3544    }
3545
3546    // 3. Generate unified stack definition with all entity views and attached program SDKs.
3547    let stack_definition = generate_stack_definition_multi(
3548        stack_name,
3549        &stack_kebab,
3550        &stack_spec.entities,
3551        &entity_names,
3552        &stack_spec.idls,
3553        &stack_spec.pdas,
3554        &stack_spec.program_ids,
3555        &schema_names,
3556        &idl_account_artifacts.account_type_names,
3557        &instructions_codegen.stack_entries,
3558        &program_configs,
3559        &config,
3560        exact_views,
3561    )?;
3562
3563    // 4. Assemble `@usearete/sdk` imports based on what was actually emitted.
3564    let imports = assemble_sdk_imports(
3565        collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
3566        !idl_account_artifacts.account_type_names.is_empty(),
3567        &instructions_codegen,
3568    );
3569
3570    Ok(TypeScriptStackOutput {
3571        imports,
3572        interfaces,
3573        stack_definition,
3574        warnings: instructions_codegen.warnings,
3575        pda_degradations: instructions_codegen.pda_degradations,
3576    })
3577}
3578
3579/// Compile a stack model whose `views` have already been projected by a
3580/// StackManifest selected-view allowlist.
3581pub fn compile_stack_spec_with_exact_views(
3582    stack_spec: SerializableStackSpec,
3583    config: Option<TypeScriptStackConfig>,
3584) -> Result<TypeScriptStackOutput, String> {
3585    compile_stack_spec_with_view_selection(stack_spec, config, true)
3586}
3587
3588/// Compile an explicit StackManifest and its exact public dependencies.
3589pub fn compile_public_artifacts(
3590    programs: &[arete_artifacts::ProgramSpecArtifact],
3591    live_spec: &arete_artifacts::LiveSpecArtifact,
3592    manifest: &arete_artifacts::StackManifestArtifact,
3593    config: Option<TypeScriptStackConfig>,
3594) -> Result<TypeScriptStackOutput, String> {
3595    let stack_spec =
3596        crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
3597    compile_stack_spec(stack_spec, config)
3598}
3599
3600/// Compile typed V2 public artifacts through the current single-live generator.
3601pub fn compile_public_artifacts_v2(
3602    programs: &[arete_artifacts::ProgramSpecArtifact],
3603    live_spec: &arete_artifacts::LiveSpecArtifactV2,
3604    manifest: &arete_artifacts::StackManifestArtifactV2,
3605    config: Option<TypeScriptStackConfig>,
3606) -> Result<TypeScriptStackOutput, String> {
3607    let stack_spec =
3608        crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
3609    compile_stack_spec_with_view_selection(stack_spec, config, true)
3610}
3611
3612/// Compile each aliased LiveSpec into an independent module and generate a
3613/// manifest-level `createSession` definition that preserves exact alias keys.
3614pub fn compile_composed_public_artifacts_v2(
3615    programs: &[arete_artifacts::ProgramSpecArtifact],
3616    live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
3617    manifest: &arete_artifacts::StackManifestArtifactV2,
3618    config: Option<TypeScriptCompositionConfig>,
3619) -> Result<TypeScriptCompositionOutput, String> {
3620    let composed =
3621        crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
3622    if composed.live_specs.is_empty() {
3623        return Err(
3624            "TypeScript session generation requires at least one aliased LiveSpec".to_string(),
3625        );
3626    }
3627    let config = config.unwrap_or_default();
3628    let live_aliases = composed
3629        .live_specs
3630        .iter()
3631        .map(|live| live.alias.as_str())
3632        .collect::<BTreeSet<_>>();
3633    if let Some(alias) = config
3634        .live_module_imports
3635        .keys()
3636        .find(|alias| !live_aliases.contains(alias.as_str()))
3637    {
3638        return Err(format!(
3639            "composition module import references unknown LiveSpec alias '{alias}'"
3640        ));
3641    }
3642    let mut outputs = Vec::with_capacity(composed.live_specs.len());
3643    let mut warnings = Vec::new();
3644    let mut pda_degradations = Vec::new();
3645
3646    let live_program_hashes = live_specs
3647        .iter()
3648        .flat_map(|(_, live)| &live.payload.programs)
3649        .map(|requirement| requirement.program_spec_hash.to_string())
3650        .collect::<BTreeSet<_>>();
3651    let independent_programs = programs
3652        .iter()
3653        .filter(|program| !live_program_hashes.contains(&program.artifact_hash.to_string()))
3654        .cloned()
3655        .collect::<Vec<_>>();
3656    let independent_program_keys = independent_programs
3657        .iter()
3658        .map(|program| {
3659            let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
3660            composition_program_key(program, &source)
3661        })
3662        .collect::<BTreeSet<_>>();
3663    if let Some(alias) = config
3664        .program_module_imports
3665        .keys()
3666        .find(|alias| !independent_program_keys.contains(alias.as_str()))
3667    {
3668        return Err(format!(
3669            "composition program module import references unknown independent program alias '{alias}'"
3670        ));
3671    }
3672    let program_collection = if independent_programs.is_empty() {
3673        None
3674    } else {
3675        let program_stack = crate::public_artifacts::stack_spec_from_program_artifacts(
3676            format!("{}Programs", composed.name),
3677            &independent_programs,
3678        )?;
3679        let mut program_config = config.stack.clone();
3680        program_config.websocket_url = None;
3681        program_config.http_url = None;
3682        program_config.programs =
3683            subset_program_configs(&program_stack, config.stack.programs.as_deref())?;
3684        let output =
3685            compile_stack_spec_with_view_selection(program_stack, Some(program_config), true)?;
3686        warnings.extend(output.warnings.iter().cloned());
3687        pda_degradations.extend(output.pda_degradations.iter().cloned());
3688        Some(TypeScriptProgramCollectionOutput {
3689            module_name: format!("{}-programs", to_kebab_case(&composed.name)),
3690            output,
3691            members: independent_programs
3692                .iter()
3693                .map(|program| {
3694                    let source = to_camel_case(&program.payload.idl_snapshot.snapshot.name);
3695                    let public = composition_program_key(program, &source);
3696                    (public, source)
3697                })
3698                .collect(),
3699        })
3700    };
3701
3702    let mut promoted_programs = Vec::new();
3703    let mut promoted_hashes = BTreeMap::<String, String>::new();
3704    for live in composed.live_specs {
3705        let mut stack_config = config.stack.clone();
3706        if let Some(endpoints) = config.live_endpoints.get(&live.alias) {
3707            stack_config.websocket_url = endpoints.websocket_url.clone();
3708            stack_config.http_url = endpoints.http_url.clone();
3709        } else {
3710            stack_config.websocket_url = None;
3711            stack_config.http_url = None;
3712        }
3713        stack_config.programs =
3714            subset_program_configs(&live.stack_spec, config.stack.programs.as_deref())?;
3715        for program in &live.stack_spec.program_specs {
3716            let source = to_camel_case(&program.idl_snapshot.snapshot.name);
3717            let hash = program
3718                .hash()
3719                .map_err(|error| error.to_string())?
3720                .to_string();
3721            if let Some(existing_hash) = promoted_hashes.get(&source) {
3722                if existing_hash != &hash {
3723                    return Err(format!(
3724                        "composition programs use duplicate generated key '{source}' for different ProgramSpecs"
3725                    ));
3726                }
3727                continue;
3728            }
3729            promoted_hashes.insert(source.clone(), hash);
3730            promoted_programs.push((source.clone(), live.alias.clone(), source));
3731        }
3732        let module_name = typescript_module_name(&live.alias);
3733        let output =
3734            compile_stack_spec_with_view_selection(live.stack_spec, Some(stack_config), true)?;
3735        warnings.extend(output.warnings.iter().cloned());
3736        pda_degradations.extend(output.pda_degradations.iter().cloned());
3737        outputs.push(TypeScriptAliasedStackOutput {
3738            alias: live.alias,
3739            module_name,
3740            output,
3741        });
3742    }
3743
3744    let session_definition = generate_session_definition(
3745        &composed.name,
3746        &outputs,
3747        &promoted_programs,
3748        program_collection.as_ref(),
3749        &config.live_module_imports,
3750        &config.program_module_imports,
3751    );
3752    Ok(TypeScriptCompositionOutput {
3753        name: composed.name,
3754        live_stacks: outputs,
3755        program_collection,
3756        session_definition,
3757        warnings,
3758        pda_degradations,
3759    })
3760}
3761
3762fn subset_program_configs(
3763    stack_spec: &SerializableStackSpec,
3764    configured: Option<&[TypeScriptProgramConfig]>,
3765) -> Result<Option<Vec<TypeScriptProgramConfig>>, String> {
3766    let Some(configured) = configured else {
3767        return Ok(None);
3768    };
3769    let by_hash = configured
3770        .iter()
3771        .map(|program| {
3772            (
3773                program.definition.program_spec_hash.as_str(),
3774                program.clone(),
3775            )
3776        })
3777        .collect::<BTreeMap<_, _>>();
3778    stack_spec
3779        .program_specs
3780        .iter()
3781        .map(|program| {
3782            let hash = program
3783                .hash()
3784                .map_err(|error| error.to_string())?
3785                .to_string();
3786            by_hash.get(hash.as_str()).cloned().ok_or_else(|| {
3787                format!("missing configured program descriptor for ProgramSpec {hash}")
3788            })
3789        })
3790        .collect::<Result<Vec<_>, _>>()
3791        .map(Some)
3792}
3793
3794fn generate_session_definition(
3795    manifest_name: &str,
3796    live_stacks: &[TypeScriptAliasedStackOutput],
3797    promoted_programs: &[(String, String, String)],
3798    program_collection: Option<&TypeScriptProgramCollectionOutput>,
3799    live_module_imports: &BTreeMap<String, String>,
3800    program_module_imports: &BTreeMap<String, String>,
3801) -> String {
3802    let manifest_pascal = safe_pascal_identifier(manifest_name);
3803    let definition_name = format!(
3804        "{}_SESSION_DEFINITION",
3805        to_screaming_snake_case(&manifest_pascal)
3806    );
3807    let imports = live_stacks
3808        .iter()
3809        .map(|live| {
3810            let import = live_module_imports
3811                .get(&live.alias)
3812                .cloned()
3813                .unwrap_or_else(|| format!("./{}.js", live.module_name));
3814            format!(
3815                "import {}Stack from '{}';",
3816                safe_pascal_identifier(&live.alias),
3817                import
3818            )
3819        })
3820        .collect::<Vec<_>>()
3821        .join("\n");
3822    let program_import = program_collection
3823        .map(|programs| {
3824            format!(
3825                "import {manifest_pascal}Programs from './{}.js';",
3826                programs.module_name
3827            )
3828        })
3829        .unwrap_or_default();
3830    let program_module_import_lines = program_module_imports
3831        .iter()
3832        .map(|(alias, import)| {
3833            format!(
3834                "import {}Program from '{}';",
3835                safe_pascal_identifier(alias),
3836                import
3837            )
3838        })
3839        .collect::<Vec<_>>()
3840        .join("\n");
3841    let program_members = program_collection
3842        .map(|programs| {
3843            let definitions = programs
3844                .members
3845                .iter()
3846                .map(|(public, source)| {
3847                    let value = if program_module_imports.contains_key(public) {
3848                        format!("{}Program", safe_pascal_identifier(public))
3849                    } else {
3850                        format!(
3851                            "{manifest_pascal}Programs.programs.{}",
3852                            typescript_property_key(source)
3853                        )
3854                    };
3855                    format!("    {}: {value},", typescript_property_key(public))
3856                })
3857                .collect::<Vec<_>>()
3858                .join("\n");
3859            let reads = programs
3860                .members
3861                .iter()
3862                .map(|(public, source)| {
3863                    format!(
3864                        "    {}: {manifest_pascal}Programs.programReads.{},",
3865                        typescript_property_key(public),
3866                        typescript_property_key(source)
3867                    )
3868                })
3869                .collect::<Vec<_>>()
3870                .join("\n");
3871            (definitions, reads)
3872        })
3873        .unwrap_or_default();
3874    let promoted_definitions = promoted_programs
3875        .iter()
3876        .map(|(public, live_alias, source)| {
3877            format!(
3878                "    {}: {}Stack.programs.{},",
3879                typescript_property_key(public),
3880                safe_pascal_identifier(live_alias),
3881                typescript_property_key(source)
3882            )
3883        })
3884        .collect::<Vec<_>>()
3885        .join("\n");
3886    let promoted_reads = promoted_programs
3887        .iter()
3888        .map(|(public, live_alias, source)| {
3889            format!(
3890                "    {}: {}Stack.programReads.{},",
3891                typescript_property_key(public),
3892                safe_pascal_identifier(live_alias),
3893                typescript_property_key(source)
3894            )
3895        })
3896        .collect::<Vec<_>>()
3897        .join("\n");
3898    let definitions = [promoted_definitions, program_members.0]
3899        .into_iter()
3900        .filter(|members| !members.is_empty())
3901        .collect::<Vec<_>>()
3902        .join("\n");
3903    let reads = [promoted_reads, program_members.1]
3904        .into_iter()
3905        .filter(|members| !members.is_empty())
3906        .collect::<Vec<_>>()
3907        .join("\n");
3908    let program_members =
3909        format!("  programs: {{\n{definitions}\n  }},\n  programReads: {{\n{reads}\n  }},");
3910    let members = live_stacks
3911        .iter()
3912        .map(|live| {
3913            format!(
3914                "    {}: {}Stack,",
3915                typescript_property_key(&live.alias),
3916                safe_pascal_identifier(&live.alias)
3917            )
3918        })
3919        .collect::<Vec<_>>()
3920        .join("\n");
3921    format!(
3922        r#"import {{ createSession, type CompositionSessionOptions }} from '@usearete/sdk';
3923{imports}
3924{program_import}
3925{program_module_import_lines}
3926
3927export const {definition_name} = {{
3928  mode: 'composition',
3929  stacks: {{
3930{members}
3931  }},
3932{program_members}
3933}} as const;
3934
3935export type {manifest_pascal}SessionDefinition = typeof {definition_name};
3936export const {manifest_screaming}_SDK = {definition_name};
3937export type {manifest_pascal}Sdk = {manifest_pascal}SessionDefinition;
3938
3939export function create{manifest_pascal}Session(
3940  options: CompositionSessionOptions<{manifest_pascal}SessionDefinition>
3941) {{
3942  return createSession({definition_name}, options);
3943}}
3944"#,
3945        imports = imports,
3946        program_import = program_import,
3947        program_module_import_lines = program_module_import_lines,
3948        definition_name = definition_name,
3949        members = members,
3950        program_members = program_members,
3951        manifest_pascal = manifest_pascal,
3952        manifest_screaming = to_screaming_snake_case(&manifest_pascal),
3953    )
3954}
3955
3956fn safe_pascal_identifier(value: &str) -> String {
3957    let mut output = value
3958        .split(|character: char| !character.is_ascii_alphanumeric())
3959        .filter(|segment| !segment.is_empty())
3960        .map(to_pascal_case)
3961        .collect::<String>();
3962    if output.is_empty() {
3963        output.push_str("Manifest");
3964    }
3965    if output
3966        .chars()
3967        .next()
3968        .is_some_and(|character| character.is_ascii_digit())
3969    {
3970        output.insert(0, 'A');
3971    }
3972    output
3973}
3974
3975fn typescript_module_name(alias: &str) -> String {
3976    let module = alias
3977        .chars()
3978        .map(|character| {
3979            if character.is_ascii_alphanumeric() {
3980                character.to_ascii_lowercase()
3981            } else {
3982                '-'
3983            }
3984        })
3985        .collect::<String>();
3986    format!("{module}-stack")
3987}
3988
3989fn composition_program_key(
3990    program: &arete_artifacts::ProgramSpecArtifact,
3991    generated_key: &str,
3992) -> String {
3993    match program.payload.program_id.as_str() {
3994        "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" => "splToken".to_string(),
3995        "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" => "splAta".to_string(),
3996        _ => generated_key.to_string(),
3997    }
3998}
3999
4000/// Assemble the `zod` + `@usearete/sdk` import lines based on which runtime
4001/// helpers the emitted code references.
4002fn assemble_sdk_imports(
4003    pda_imports: PdaImportUsage,
4004    has_account_reads: bool,
4005    instructions_codegen: &crate::typescript_instructions::InstructionsCodegen,
4006) -> String {
4007    let mut sdk_named: Vec<String> = Vec::new();
4008    for (needed, helper) in [
4009        (pda_imports.pda, "pda"),
4010        (pda_imports.literal, "literal"),
4011        (pda_imports.account, "account"),
4012        (pda_imports.arg, "arg"),
4013        (pda_imports.bytes, "bytes"),
4014    ] {
4015        if needed {
4016            sdk_named.push(helper.to_string());
4017        }
4018    }
4019    if has_account_reads {
4020        sdk_named.push("programAccountRead".to_string());
4021    }
4022    if instructions_codegen.needs_runtime_import {
4023        sdk_named.push("createInstructionHandler".to_string());
4024        sdk_named.push("type ErrorMetadata".to_string());
4025    }
4026    if !instructions_codegen.stack_entries.is_empty() {
4027        sdk_named.push("buildInstruction".to_string());
4028    }
4029    if instructions_codegen.needs_build_options {
4030        sdk_named.push("type BuildOptions".to_string());
4031    }
4032    if instructions_codegen.needs_program_runtime_extensions {
4033        sdk_named.push("PROGRAM_OPERATION_EXTENSIONS".to_string());
4034        sdk_named.push("instructionOperation".to_string());
4035        sdk_named.push("createPreparedInstruction".to_string());
4036    }
4037    if instructions_codegen.needs_operation_context {
4038        sdk_named.push("type ProgramOperationContext".to_string());
4039    }
4040    if instructions_codegen.needs_amount_input {
4041        sdk_named.push("type AmountInput".to_string());
4042    }
4043    if instructions_codegen.needs_resolve_amount_to_raw {
4044        sdk_named.push("resolveAmountToRaw".to_string());
4045    }
4046    if instructions_codegen.needs_to_raw_amount {
4047        sdk_named.push("toRawAmount".to_string());
4048    }
4049    if sdk_named.is_empty() {
4050        "import { z } from 'zod';".to_string()
4051    } else {
4052        format!(
4053            "import {{ z }} from 'zod';\nimport {{ {} }} from '@usearete/sdk';",
4054            sdk_named.join(", ")
4055        )
4056    }
4057}
4058
4059#[derive(Debug, Clone, Copy, Default)]
4060struct PdaImportUsage {
4061    pda: bool,
4062    literal: bool,
4063    account: bool,
4064    arg: bool,
4065    bytes: bool,
4066}
4067
4068fn collect_emitted_pda_imports(
4069    idls: &[IdlSnapshot],
4070    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4071) -> PdaImportUsage {
4072    let mut usage = PdaImportUsage::default();
4073
4074    for idl in idls {
4075        let Some(program_pdas) = pdas
4076            .get(&idl.name)
4077            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4078            .filter(|program_pdas| !program_pdas.is_empty())
4079        else {
4080            continue;
4081        };
4082
4083        usage.pda = true;
4084        for seed in program_pdas.values().flat_map(|pda| &pda.seeds) {
4085            match seed {
4086                PdaSeedDef::Literal { .. } => usage.literal = true,
4087                PdaSeedDef::AccountRef { .. } => usage.account = true,
4088                PdaSeedDef::ArgRef { .. } => usage.arg = true,
4089                PdaSeedDef::Bytes { .. } => usage.bytes = true,
4090            }
4091        }
4092    }
4093
4094    usage
4095}
4096
4097/// Compile only the program-SDK surface of a stack spec — account types +
4098/// Zod schemas, instruction handlers, and standalone per-program consts.
4099/// No entities, views, or stack const are emitted, and the spec's `entities`
4100/// may be empty. Used by `a4 sdk create --ts --program-only`.
4101pub fn compile_program_modules(
4102    stack_spec: SerializableStackSpec,
4103    config: Option<TypeScriptStackConfig>,
4104) -> Result<TypeScriptStackOutput, String> {
4105    let config = config.unwrap_or_default();
4106    let program_configs = resolve_program_configs(&stack_spec, config.programs.as_deref(), false)?;
4107    let stack_name = &stack_spec.stack_name;
4108
4109    if stack_spec.idls.is_empty() {
4110        return Err(format!(
4111            "Stack '{}' carries no IDLs; a program-only SDK has nothing to emit",
4112            stack_name
4113        ));
4114    }
4115
4116    let mut reserved_type_names: std::collections::HashSet<String> =
4117        std::collections::HashSet::new();
4118    let idl_account_artifacts =
4119        generate_idl_account_artifacts(&stack_spec.idls, &reserved_type_names);
4120    for type_name in &idl_account_artifacts.type_names {
4121        reserved_type_names.insert(type_name.clone());
4122    }
4123
4124    let mut interfaces = idl_account_artifacts.code.clone();
4125
4126    let instructions_codegen = crate::typescript_instructions::generate_instructions_code(
4127        stack_name,
4128        &stack_spec.instructions,
4129        &stack_spec.idls,
4130        &stack_spec.pdas,
4131        &stack_spec.program_ids,
4132        &reserved_type_names,
4133    );
4134    if !instructions_codegen.code.is_empty() {
4135        if interfaces.is_empty() {
4136            interfaces = instructions_codegen.code.clone();
4137        } else {
4138            interfaces = format!("{}\n\n{}", interfaces, instructions_codegen.code);
4139        }
4140    }
4141
4142    let unique_schemas: BTreeSet<String> =
4143        idl_account_artifacts.schema_names.iter().cloned().collect();
4144
4145    let program_context = ProgramGenerationContext {
4146        pdas: &stack_spec.pdas,
4147        program_ids: &stack_spec.program_ids,
4148        instruction_entries: &instructions_codegen.stack_entries,
4149        schema_names: &unique_schemas,
4150        account_type_names: &idl_account_artifacts.account_type_names,
4151        programs: &program_configs,
4152    };
4153    let stack_definition =
4154        generate_program_definitions(stack_name, &stack_spec.idls, &program_context);
4155
4156    let imports = assemble_sdk_imports(
4157        collect_emitted_pda_imports(&stack_spec.idls, &stack_spec.pdas),
4158        !idl_account_artifacts.account_type_names.is_empty(),
4159        &instructions_codegen,
4160    );
4161
4162    Ok(TypeScriptStackOutput {
4163        imports,
4164        interfaces,
4165        stack_definition,
4166        warnings: instructions_codegen.warnings,
4167        pda_degradations: instructions_codegen.pda_degradations,
4168    })
4169}
4170
4171/// Compile standalone program SDK modules directly from ProgramSpec artifacts.
4172pub fn compile_program_artifacts(
4173    name: impl Into<String>,
4174    programs: &[arete_artifacts::ProgramSpecArtifact],
4175    config: Option<TypeScriptStackConfig>,
4176) -> Result<TypeScriptStackOutput, String> {
4177    let stack_spec = crate::public_artifacts::stack_spec_from_program_artifacts(name, programs)?;
4178    compile_program_modules(stack_spec, config)
4179}
4180
4181/// Write stack-level TypeScript output to a file
4182pub fn write_stack_typescript_to_file(
4183    output: &TypeScriptStackOutput,
4184    path: &std::path::Path,
4185) -> Result<(), std::io::Error> {
4186    std::fs::write(path, output.full_file())
4187}
4188
4189/// Generate a unified stack definition for multiple entities.
4190///
4191/// Produces something like:
4192/// ```typescript
4193/// export const ORE_STACK = {
4194///   name: 'ore',
4195///   url: 'wss://ore.stack.arete.run',
4196///   views: {
4197///     OreRound: {
4198///       state: stateView<OreRound>('OreRound/state'),
4199///       list: listView<OreRound>('OreRound/list'),
4200///       latest: listView<OreRound>('OreRound/latest'),
4201///     },
4202///     OreTreasury: {
4203///       state: stateView<OreTreasury>('OreTreasury/state'),
4204///     },
4205///     OreMiner: {
4206///       state: stateView<OreMiner>('OreMiner/state'),
4207///       list: listView<OreMiner>('OreMiner/list'),
4208///     },
4209///   },
4210/// } as const;
4211/// ```
4212#[allow(clippy::too_many_arguments)]
4213fn generate_stack_definition_multi(
4214    stack_name: &str,
4215    stack_kebab: &str,
4216    entities: &[SerializableStreamSpec],
4217    entity_names: &[String],
4218    idls: &[IdlSnapshot],
4219    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4220    program_ids: &[String],
4221    schema_names: &[String],
4222    account_type_names: &BTreeMap<(String, String), String>,
4223    instruction_entries: &[crate::typescript_instructions::StackInstructionEntry],
4224    program_configs: &[TypeScriptProgramConfig],
4225    config: &TypeScriptStackConfig,
4226    exact_views: bool,
4227) -> Result<String, String> {
4228    let export_name = format!(
4229        "{}_{}",
4230        to_screaming_snake_case(stack_name),
4231        config.export_const_name
4232    );
4233    let core_export_name = format!("{}_CORE", export_name);
4234
4235    let view_helpers = generate_view_helpers_static();
4236
4237    let websocket_endpoint = match &config.websocket_url {
4238        Some(url) => format!("    ws: '{}',", url),
4239        None => "    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })"
4240            .to_string(),
4241    };
4242    let http_endpoint = match &config.http_url {
4243        Some(url) => format!("    http: '{}',", url),
4244        None => {
4245            "    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
4246                .to_string()
4247        }
4248    };
4249    let endpoints_block = format!(
4250        "  endpoints: {{\n{}\n{}\n  }},",
4251        websocket_endpoint, http_endpoint
4252    );
4253
4254    // Generate views block for each entity
4255    let mut entity_view_blocks = Vec::new();
4256    for (i, entity_spec) in entities.iter().enumerate() {
4257        let entity_name = &entity_names[i];
4258        let entity_pascal = to_pascal_case(entity_name);
4259        let mut view_entries = Vec::new();
4260
4261        if !exact_views
4262            || entity_spec
4263                .views
4264                .iter()
4265                .any(|view| view.id == format!("{entity_name}/state"))
4266        {
4267            let state_view_key = state_view_key_definition(
4268                entity_name,
4269                &entity_spec.identity,
4270                &entity_spec.field_mappings,
4271                &entity_spec.sections,
4272            )?;
4273            view_entries.push(format!(
4274                "      state: stateView<{entity}, {key_type}>('{entity_name}/state', {key_fields}),",
4275                entity = entity_pascal,
4276                entity_name = entity_name,
4277                key_type = state_view_key.object_type(),
4278                key_fields = state_view_key.fields_literal(),
4279            ));
4280        }
4281
4282        if !exact_views
4283            || entity_spec
4284                .views
4285                .iter()
4286                .any(|view| view.id == format!("{entity_name}/list"))
4287        {
4288            view_entries.push(format!(
4289                "      list: listView<{entity}>('{entity_name}/list'),",
4290                entity = entity_pascal,
4291                entity_name = entity_name
4292            ));
4293        }
4294
4295        for view in &entity_spec.views {
4296            if !view.id.ends_with("/state")
4297                && !view.id.ends_with("/list")
4298                && view.id.starts_with(entity_name)
4299            {
4300                let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
4301                view_entries.push(format!(
4302                    "      {}: listView<{entity}>('{}'),",
4303                    typescript_property_key(view_name),
4304                    view.id,
4305                    entity = entity_pascal
4306                ));
4307            }
4308        }
4309
4310        if !view_entries.is_empty() {
4311            entity_view_blocks.push(format!(
4312                "    {}: {{\n{}\n    }},",
4313                typescript_property_key(entity_name),
4314                view_entries.join("\n")
4315            ));
4316        }
4317    }
4318
4319    let views_body = entity_view_blocks.join("\n");
4320
4321    let mut unique_schemas: BTreeSet<String> = BTreeSet::new();
4322    for name in schema_names {
4323        unique_schemas.insert(name.clone());
4324    }
4325    let schemas_block = if unique_schemas.is_empty() {
4326        String::new()
4327    } else {
4328        let schema_entries: Vec<String> = unique_schemas
4329            .iter()
4330            .filter(|name| name.ends_with("Schema") && !name.ends_with("PatchSchema"))
4331            .map(|name| format!("    {}: {},", name.trim_end_matches("Schema"), name))
4332            .collect();
4333        if schema_entries.is_empty() {
4334            String::new()
4335        } else {
4336            format!("\n  schemas: {{\n{}\n  }},", schema_entries.join("\n"))
4337        }
4338    };
4339    let patch_schema_entries: Vec<String> = entity_names
4340        .iter()
4341        .map(|entity_name| {
4342            let entity_pascal = to_pascal_case(entity_name);
4343            format!("    {}: {}PatchSchema,", entity_pascal, entity_pascal)
4344        })
4345        .collect();
4346    let patch_schemas_block = if patch_schema_entries.is_empty() {
4347        String::new()
4348    } else {
4349        format!(
4350            "\n  patchSchemas: {{\n{}\n  }},",
4351            patch_schema_entries.join("\n")
4352        )
4353    };
4354
4355    let program_context = ProgramGenerationContext {
4356        pdas,
4357        program_ids,
4358        instruction_entries,
4359        schema_names: &unique_schemas,
4360        account_type_names,
4361        programs: program_configs,
4362    };
4363    let programs_block = generate_programs_block(idls, &program_context);
4364    let program_reads_block = generate_program_reads_block(idls, &program_context);
4365    let addresses_block = generate_stack_addresses_block(idls, pdas, program_ids);
4366
4367    let entity_types: Vec<String> = entity_names.iter().map(|n| to_pascal_case(n)).collect();
4368
4369    let stack_export = format!(
4370        r#"export const {core_export_name} = {{
4371  name: '{stack_kebab}',
4372{endpoints_block}
4373  views: {{
4374{views_body}
4375  }},{schemas_section}{patch_schemas_section}{programs_section}{program_reads_section}{addresses_section}
4376}} as const;"#,
4377        core_export_name = core_export_name,
4378        stack_kebab = stack_kebab,
4379        endpoints_block = endpoints_block,
4380        views_body = views_body,
4381        schemas_section = schemas_block,
4382        patch_schemas_section = patch_schemas_block,
4383        programs_section = programs_block,
4384        program_reads_section = program_reads_block,
4385        addresses_section = addresses_block,
4386    );
4387
4388    Ok(format!(
4389        r#"{view_helpers}
4390
4391// ============================================================================
4392// Stack Definition
4393// ============================================================================
4394
4395/** Stack definition for {stack_name} with {entity_count} entities */
4396{stack_export}
4397
4398/** Type alias for the core stack */
4399export type {stack_name}CoreStack = typeof {core_export_name};
4400
4401/** Entity types in this stack */
4402export type {stack_name}Entity = {entity_union};
4403
4404/** Default export for convenience */
4405export default {core_export_name};"#,
4406        view_helpers = view_helpers,
4407        stack_name = stack_name,
4408        entity_count = entities.len(),
4409        core_export_name = core_export_name,
4410        stack_export = stack_export,
4411        entity_union = if entity_types.is_empty() {
4412            "never".to_string()
4413        } else {
4414            entity_types.join(" | ")
4415        },
4416    ))
4417}
4418
4419fn typescript_property_key(value: &str) -> String {
4420    let mut characters = value.chars();
4421    let valid = characters
4422        .next()
4423        .is_some_and(|character| character.is_ascii_alphabetic() || matches!(character, '_' | '$'))
4424        && characters
4425            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '$'));
4426    if valid {
4427        value.to_string()
4428    } else {
4429        serde_json::to_string(value).expect("string property serialization cannot fail")
4430    }
4431}
4432
4433struct ProgramGenerationContext<'a> {
4434    pdas: &'a BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4435    program_ids: &'a [String],
4436    instruction_entries: &'a [crate::typescript_instructions::StackInstructionEntry],
4437    schema_names: &'a BTreeSet<String>,
4438    account_type_names: &'a BTreeMap<(String, String), String>,
4439    programs: &'a [TypeScriptProgramConfig],
4440}
4441
4442/// Build one program's `{ name, programId, pdas?, accounts?, instructions? }`
4443/// literal body. Sections are indented for nesting inside a stack const
4444/// (`programs: { <key>: { ... } }`); callers emitting top-level program
4445/// consts dedent them.
4446fn generate_single_program_sections(
4447    idl: &IdlSnapshot,
4448    index: usize,
4449    context: &ProgramGenerationContext<'_>,
4450) -> (String, Vec<String>) {
4451    let program_key = to_camel_case(&idl.name);
4452    let metadata = &context.programs[index];
4453    let multi_program = context.program_ids.len() > 1
4454        || context
4455            .instruction_entries
4456            .iter()
4457            .any(|entry| entry.program_key.is_some());
4458    let program_id = context
4459        .program_ids
4460        .get(index)
4461        .cloned()
4462        .or_else(|| idl.program_id.clone())
4463        .unwrap_or_default();
4464
4465    let instruction_entries_for_program: Vec<
4466        &crate::typescript_instructions::StackInstructionEntry,
4467    > = context
4468        .instruction_entries
4469        .iter()
4470        .filter(|entry| {
4471            if multi_program {
4472                entry.program_key.as_deref() == Some(program_key.as_str())
4473            } else {
4474                true
4475            }
4476        })
4477        .collect();
4478    let instruction_entry_literals: Vec<String> = instruction_entries_for_program
4479        .iter()
4480        .map(|entry| {
4481            format!(
4482                "        {}: {},",
4483                entry.instruction_name, entry.handler_const
4484            )
4485        })
4486        .collect();
4487
4488    let account_entries: Vec<String> = idl
4489        .accounts
4490        .iter()
4491        .filter_map(|account| {
4492            let type_name = context
4493                .account_type_names
4494                .get(&(program_key.clone(), account.name.clone()))?
4495                .clone();
4496            let schema_name = format!("{}Schema", type_name);
4497            if !context.schema_names.contains(&schema_name) {
4498                return None;
4499            }
4500            Some((account.name.clone(), type_name, schema_name))
4501        })
4502        .map(|account| {
4503            format!(
4504                "        {account_name}: programAccountRead<{type_name}>({{ account: '{account_name}', schema: {schema_name} }}),",
4505                account_name = account.0,
4506                type_name = account.1,
4507                schema_name = account.2,
4508            )
4509        })
4510        .collect();
4511
4512    let program_pdas = context
4513        .pdas
4514        .get(&idl.name)
4515        .or_else(|| context.pdas.get(&program_key))
4516        .filter(|program_pdas| !program_pdas.is_empty());
4517
4518    let mut sections: Vec<String> = vec![
4519        format!("      name: '{}',", idl.name),
4520        format!("      programId: '{}',", program_id),
4521    ];
4522    if let Some(definition_hash) = &metadata.definition.sdk_definition_hash {
4523        sections.push(format!("      sdkDefinitionHash: '{}',", definition_hash));
4524    }
4525    sections.extend([
4526        format!(
4527            "      programSpecHash: '{}',",
4528            metadata.definition.program_spec_hash
4529        ),
4530        format!(
4531            "      idlContentHash: '{}',",
4532            metadata.definition.idl_content_hash
4533        ),
4534        format!(
4535            "      normalizedIdlHash: '{}',",
4536            metadata.definition.normalized_idl_hash
4537        ),
4538    ]);
4539
4540    if let Some(program_pdas) = program_pdas {
4541        let pda_entries = generate_program_pda_entries(program_pdas, &program_id, "        ");
4542        if !pda_entries.is_empty() {
4543            sections.push(format!(
4544                "      pdas: {{\n{}\n      }},",
4545                pda_entries.join("\n")
4546            ));
4547            sections.push(format!(
4548                "      addresses: {{\n{}\n      }},",
4549                pda_entries.join("\n")
4550            ));
4551        }
4552    }
4553
4554    if !account_entries.is_empty() {
4555        sections.push(format!(
4556            "      accounts: {{\n{}\n      }},",
4557            account_entries.join("\n")
4558        ));
4559    }
4560
4561    if !instruction_entry_literals.is_empty() {
4562        sections.push(format!(
4563            "      rawInstructions: {{\n{}\n      }},",
4564            instruction_entry_literals.join("\n")
4565        ));
4566        if let Some(semantic_block) =
4567            generate_program_semantic_instructions_block(&instruction_entries_for_program, "      ")
4568        {
4569            sections.push(semantic_block);
4570        }
4571    }
4572
4573    (program_key, sections)
4574}
4575
4576fn generate_programs_block(idls: &[IdlSnapshot], context: &ProgramGenerationContext<'_>) -> String {
4577    if idls.is_empty() || context.programs.is_empty() {
4578        return String::new();
4579    }
4580
4581    let mut program_blocks = Vec::new();
4582
4583    for (index, idl) in idls.iter().enumerate() {
4584        let (program_key, sections) = generate_single_program_sections(idl, index, context);
4585
4586        program_blocks.push(format!(
4587            "    {}: {{\n{}\n    }},",
4588            program_key,
4589            sections.join("\n")
4590        ));
4591    }
4592
4593    if program_blocks.is_empty() {
4594        return String::new();
4595    }
4596
4597    format!("\n  programs: {{\n{}\n  }},", program_blocks.join("\n"))
4598}
4599
4600fn generate_program_read_sections(metadata: &TypeScriptProgramConfig, indent: &str) -> Vec<String> {
4601    let mut sections = vec![format!(
4602        "{indent}release: {{ programReleaseHash: {release_hash}, programSpecHash: {spec_hash} }},",
4603        release_hash = serde_json::to_string(&metadata.release.program_release_hash)
4604            .expect("program release hash must serialize"),
4605        spec_hash = serde_json::to_string(&metadata.release.program_spec_hash)
4606            .expect("program spec hash must serialize"),
4607    )];
4608    sections.push(match &metadata.transport {
4609        TypeScriptProgramReadTransport::LocalHttp => format!(
4610            "{indent}transport: {{ kind: 'local-http', endpointSource: 'connect-http-url' }},"
4611        ),
4612        TypeScriptProgramReadTransport::HostedBinding(binding) => format!(
4613            "{indent}transport: {{ kind: 'hosted-binding', binding: {{ endpoint: {endpoint}, programReadBindingId: {binding_id}, auth: {auth} }} }},",
4614            endpoint = serde_json::to_string(&binding.endpoint)
4615                .expect("program endpoint must serialize"),
4616            binding_id = serde_json::to_string(&binding.program_read_binding_id)
4617                .expect("program binding ID must serialize"),
4618            auth = serde_json::to_string(&binding.auth)
4619                .expect("program auth metadata must serialize"),
4620        ),
4621    });
4622    sections
4623}
4624
4625fn generate_program_reads_block(
4626    idls: &[IdlSnapshot],
4627    context: &ProgramGenerationContext<'_>,
4628) -> String {
4629    if idls.is_empty() || context.programs.is_empty() {
4630        return String::new();
4631    }
4632
4633    let entries = idls
4634        .iter()
4635        .zip(context.programs)
4636        .map(|(idl, metadata)| {
4637            format!(
4638                "    {}: {{\n{}\n    }},",
4639                to_camel_case(&idl.name),
4640                generate_program_read_sections(metadata, "      ").join("\n")
4641            )
4642        })
4643        .collect::<Vec<_>>();
4644    format!("\n  programReads: {{\n{}\n  }},", entries.join("\n"))
4645}
4646
4647/// Strip up to `spaces` leading spaces from every line.
4648fn dedent_lines(text: &str, spaces: usize) -> String {
4649    text.lines()
4650        .map(|line| {
4651            let strip = line
4652                .char_indices()
4653                .take_while(|(i, c)| *i < spaces && *c == ' ')
4654                .count();
4655            &line[strip..]
4656        })
4657        .collect::<Vec<_>>()
4658        .join("\n")
4659}
4660
4661/// Emit standalone per-program consts plus a combined `<STACK>_PROGRAMS` map:
4662///
4663/// ```typescript
4664/// export const SQUADS_MULTISIG_PROGRAM = { name, programId, pdas, accounts, instructions } as const;
4665/// export const SQUADS_V4_PROGRAMS = { squadsMultisigProgram: SQUADS_MULTISIG_PROGRAM } as const;
4666/// ```
4667///
4668/// Each const structurally satisfies the runtime's `ProgramSdkDefinition`, so
4669/// the map can be dropped straight into `createSession({ programs: ... })`.
4670fn generate_program_definitions(
4671    stack_name: &str,
4672    idls: &[IdlSnapshot],
4673    context: &ProgramGenerationContext<'_>,
4674) -> String {
4675    let mut program_consts = Vec::new();
4676    let mut map_entries = Vec::new();
4677    let mut program_read_consts = Vec::new();
4678    let mut read_map_entries = Vec::new();
4679
4680    for (index, idl) in idls.iter().enumerate() {
4681        let (program_key, sections) = generate_single_program_sections(idl, index, context);
4682        let const_name = to_screaming_snake_case(&idl.name);
4683        let body = dedent_lines(&sections.join("\n"), 4);
4684        program_consts.push(format!(
4685            "/** Standalone program SDK for '{name}' */\nexport const {const_name} = {{\n{body}\n}} as const;",
4686            name = idl.name,
4687            const_name = const_name,
4688            body = body,
4689        ));
4690        map_entries.push(format!("  {}: {},", program_key, const_name));
4691        let read_const_name = format!("{}_READ", const_name);
4692        let read_body = dedent_lines(
4693            &generate_program_read_sections(&context.programs[index], "    ").join("\n"),
4694            4,
4695        );
4696        program_read_consts.push(format!(
4697            "/** Release and explicit read transport for '{name}' */\nexport const {read_const_name} = {{\n{read_body}\n}} as const;",
4698            name = idl.name,
4699        ));
4700        read_map_entries.push(format!("  {}: {},", program_key, read_const_name));
4701    }
4702
4703    let map_name = format!("{}_PROGRAMS", to_screaming_snake_case(stack_name));
4704    let reads_map_name = format!("{}_PROGRAM_READS", to_screaming_snake_case(stack_name));
4705    let type_name = format!("{}Programs", to_pascal_case(stack_name));
4706
4707    format!(
4708        r#"// ============================================================================
4709// Program Definitions
4710// ============================================================================
4711
4712{program_consts}
4713
4714{program_read_consts}
4715
4716/** All portable programs from the {stack_name} stack */
4717export const {map_name} = {{
4718{map_entries}
4719}} as const;
4720
4721/** Parallel release/read metadata keyed identically to {map_name} */
4722export const {reads_map_name} = {{
4723{read_map_entries}
4724}} as const;
4725
4726export type {type_name} = typeof {map_name};
4727
4728export default {map_name};"#,
4729        program_consts = program_consts.join("\n\n"),
4730        program_read_consts = program_read_consts.join("\n\n"),
4731        stack_name = stack_name,
4732        map_name = map_name,
4733        map_entries = map_entries.join("\n"),
4734        reads_map_name = reads_map_name,
4735        read_map_entries = read_map_entries.join("\n"),
4736        type_name = type_name,
4737    )
4738}
4739
4740fn generate_program_pda_entries(
4741    program_pdas: &BTreeMap<String, PdaDefinition>,
4742    default_program_id: &str,
4743    indent: &str,
4744) -> Vec<String> {
4745    if program_pdas.is_empty() {
4746        return Vec::new();
4747    }
4748
4749    program_pdas
4750        .iter()
4751        .map(|(pda_name, pda_def)| {
4752            let seeds_str = pda_def
4753                .seeds
4754                .iter()
4755                .map(|seed| match seed {
4756                    PdaSeedDef::Literal { value } => format!("literal('{}')", value),
4757                    PdaSeedDef::AccountRef { account_name } => {
4758                        format!("account('{}')", account_name)
4759                    }
4760                    PdaSeedDef::ArgRef { arg_name, arg_type } => {
4761                        if let Some(t) = arg_type {
4762                            format!("arg('{}', '{}')", arg_name, t)
4763                        } else {
4764                            format!("arg('{}')", arg_name)
4765                        }
4766                    }
4767                    PdaSeedDef::Bytes { value } => {
4768                        let bytes_arr: Vec<String> = value.iter().map(|b| b.to_string()).collect();
4769                        format!("bytes(new Uint8Array([{}]))", bytes_arr.join(", "))
4770                    }
4771                })
4772                .collect::<Vec<_>>()
4773                .join(", ");
4774
4775            let pid = pda_def.program_id.as_deref().unwrap_or(default_program_id);
4776            let rendered_seeds = if seeds_str.is_empty() {
4777                String::new()
4778            } else {
4779                format!(", {}", seeds_str)
4780            };
4781            format!("{}{}: pda('{}'{}),", indent, pda_name, pid, rendered_seeds)
4782        })
4783        .collect()
4784}
4785
4786fn generate_stack_addresses_block(
4787    idls: &[IdlSnapshot],
4788    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4789    program_ids: &[String],
4790) -> String {
4791    if idls.is_empty() {
4792        return String::new();
4793    }
4794
4795    if idls.len() == 1 {
4796        let idl = &idls[0];
4797        let program_id = program_ids
4798            .first()
4799            .cloned()
4800            .or_else(|| idl.program_id.clone())
4801            .unwrap_or_default();
4802        let Some(program_pdas) = pdas
4803            .get(&idl.name)
4804            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4805        else {
4806            return String::new();
4807        };
4808        let entries = generate_program_pda_entries(program_pdas, &program_id, "    ");
4809        if entries.is_empty() {
4810            return String::new();
4811        }
4812        return format!("\n  addresses: {{\n{}\n  }},", entries.join("\n"));
4813    }
4814
4815    let mut blocks = Vec::new();
4816    for (index, idl) in idls.iter().enumerate() {
4817        let program_id = program_ids
4818            .get(index)
4819            .cloned()
4820            .or_else(|| idl.program_id.clone())
4821            .unwrap_or_default();
4822        let Some(program_pdas) = pdas
4823            .get(&idl.name)
4824            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4825        else {
4826            continue;
4827        };
4828        let entries = generate_program_pda_entries(program_pdas, &program_id, "      ");
4829        if entries.is_empty() {
4830            continue;
4831        }
4832        blocks.push(format!(
4833            "    {}: {{\n{}\n    }},",
4834            to_camel_case(&idl.name),
4835            entries.join("\n")
4836        ));
4837    }
4838
4839    if blocks.is_empty() {
4840        String::new()
4841    } else {
4842        format!("\n  addresses: {{\n{}\n  }},", blocks.join("\n"))
4843    }
4844}
4845
4846fn is_valid_ts_identifier(name: &str) -> bool {
4847    name.chars()
4848        .next()
4849        .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
4850        .unwrap_or(false)
4851        && name
4852            .chars()
4853            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
4854}
4855
4856fn escape_ts_single_quotes(value: &str) -> String {
4857    value
4858        .replace('\\', "\\\\")
4859        .replace('\'', "\\'")
4860        .replace(['\n', '\r'], " ")
4861}
4862
4863fn render_ts_property_name_literal(name: &str) -> String {
4864    if is_valid_ts_identifier(name) {
4865        name.to_string()
4866    } else {
4867        format!("'{}'", escape_ts_single_quotes(name))
4868    }
4869}
4870
4871fn render_program_semantic_instruction_entry(
4872    entry: &crate::typescript_instructions::StackInstructionEntry,
4873    indent: &str,
4874) -> Option<String> {
4875    let semantic_params_type = entry.semantic_params_type.as_ref()?;
4876    entry.runtime_program_key.as_ref()?;
4877
4878    if entry.semantic_amount_args.is_empty() {
4879        return Some(format!(
4880            "{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}}}),",
4881            indent = indent,
4882            instruction_name = entry.instruction_name,
4883            semantic_params_type = semantic_params_type,
4884            handler_const = entry.handler_const,
4885        ));
4886    }
4887
4888    let raw_params_setup = if entry.semantic_extra_params.is_empty() {
4889        format!(
4890            "{indent}    const {{ build, ...rawParams }} = params;",
4891            indent = indent
4892        )
4893    } else {
4894        format!(
4895            "{indent}    const {{ build, {extras}, ...rawParams }} = params;",
4896            indent = indent,
4897            extras = entry.semantic_extra_params.join(", "),
4898        )
4899    };
4900    let resolution_lines: Vec<String> = entry
4901        .semantic_amount_args
4902        .iter()
4903        .map(|amount_arg| {
4904            format!(
4905                "{indent}    const {binding_name} = {raw_expression};",
4906                indent = indent,
4907                binding_name = amount_arg.binding_name,
4908                raw_expression = amount_arg.raw_expression,
4909            )
4910        })
4911        .collect();
4912    let raw_assignments: Vec<String> = entry
4913        .semantic_amount_args
4914        .iter()
4915        .map(|amount_arg| {
4916            format!(
4917                "{indent}      {}: {},",
4918                render_ts_property_name_literal(&amount_arg.arg_name),
4919                amount_arg.binding_name,
4920                indent = indent,
4921            )
4922        })
4923        .collect();
4924
4925    Some(format!(
4926        "{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}  }}),",
4927        indent = indent,
4928        instruction_name = entry.instruction_name,
4929        semantic_params_type = semantic_params_type,
4930        raw_params_setup = raw_params_setup,
4931        resolutions = resolution_lines.join("\n"),
4932        handler_const = entry.handler_const,
4933        assignments = raw_assignments.join("\n"),
4934    ))
4935}
4936
4937fn generate_program_semantic_instructions_block(
4938    instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
4939    indent: &str,
4940) -> Option<String> {
4941    let entry_indent = format!("{}      ", indent);
4942    let entries: Vec<String> = instruction_entries
4943        .iter()
4944        .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
4945        .collect();
4946    if entries.is_empty() {
4947        return None;
4948    }
4949
4950    let context_param = if instruction_entries
4951        .iter()
4952        .any(|entry| entry.uses_operation_context)
4953    {
4954        "context: ProgramOperationContext"
4955    } else {
4956        ""
4957    };
4958
4959    Some(format!(
4960        "{indent}[PROGRAM_OPERATION_EXTENSIONS]: {{\n{indent}  createOperations({context_param}) {{\n{indent}    return {{\n{indent}      instructions: {{\n{entries}\n{indent}      }},\n{indent}    }};\n{indent}  }},\n{indent}}},",
4961        indent = indent,
4962        context_param = context_param,
4963        entries = entries.join("\n"),
4964    ))
4965}
4966
4967fn generate_view_helpers_static() -> String {
4968    r#"// ============================================================================
4969// View Definition Types (framework-agnostic)
4970// ============================================================================
4971
4972export type ViewKeyFields<TKey> = unknown extends TKey
4973  ? readonly string[]
4974  : TKey extends object
4975    ? readonly Extract<keyof TKey, string>[]
4976    : readonly string[];
4977
4978/** View definition with embedded entity and state-key types */
4979export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown> {
4980  readonly mode: TMode;
4981  readonly view: string;
4982  readonly keyFields?: ViewKeyFields<TKey>;
4983  /** Phantom field for type inference - not present at runtime */
4984  readonly _entity?: T;
4985  readonly _key?: TKey;
4986}
4987
4988/** Helper to create typed state view definitions (keyed lookups) */
4989function stateView<T, TKey = unknown>(
4990  view: string,
4991  keyFields: ViewKeyFields<TKey>
4992): ViewDef<T, 'state', TKey> {
4993  return { mode: 'state', view, keyFields } as const;
4994}
4995
4996/** Helper to create typed list view definitions (collections) */
4997function listView<T>(view: string): ViewDef<T, 'list'> {
4998  return { mode: 'list', view } as const;
4999}"#
5000    .to_string()
5001}
5002
5003/// Convert PascalCase to SCREAMING_SNAKE_CASE (e.g., "OreStream" -> "ORE_STREAM")
5004pub(crate) fn to_screaming_snake_case(s: &str) -> String {
5005    let mut result = String::new();
5006    for (i, ch) in s.chars().enumerate() {
5007        if ch.is_uppercase() && i > 0 {
5008            result.push('_');
5009        }
5010        result.push(ch.to_uppercase().next().unwrap());
5011    }
5012    result
5013}
5014
5015#[cfg(test)]
5016mod tests {
5017    use super::*;
5018
5019    fn demo_program_spec() -> arete_hash::ProgramSpecV1 {
5020        arete_hash::build_program_spec_v1_from_bytes(
5021            br#"{
5022              "address":"Prog111",
5023              "version":"0.1.0",
5024              "name":"demo",
5025              "instructions":[],
5026              "accounts":[],
5027              "types":[],
5028              "events":[],
5029              "errors":[]
5030            }"#,
5031            None,
5032        )
5033        .expect("test ProgramSpecV1")
5034    }
5035
5036    fn named_program_spec(name: &str, program_id: &str) -> arete_hash::ProgramSpecV1 {
5037        arete_hash::build_program_spec_v1_from_bytes(
5038            format!(
5039                r#"{{
5040                  "address":"{program_id}",
5041                  "version":"0.1.0",
5042                  "name":"{name}",
5043                  "instructions":[],
5044                  "accounts":[],
5045                  "types":[],
5046                  "events":[],
5047                  "errors":[]
5048                }}"#
5049            )
5050            .as_bytes(),
5051            None,
5052        )
5053        .expect("named test ProgramSpecV1")
5054    }
5055
5056    fn two_program_test_spec() -> SerializableStackSpec {
5057        let specs = vec![
5058            named_program_spec("second_program", "Program222"),
5059            named_program_spec("first_program", "Program111"),
5060        ];
5061        SerializableStackSpec {
5062            ast_version: CURRENT_AST_VERSION.to_string(),
5063            stack_name: "OrderedStream".to_string(),
5064            program_ids: specs.iter().map(|spec| spec.program_id.clone()).collect(),
5065            idls: specs
5066                .iter()
5067                .map(|spec| spec.idl_snapshot.snapshot.clone())
5068                .collect(),
5069            program_specs: specs,
5070            entities: vec![],
5071            pdas: BTreeMap::new(),
5072            instructions: vec![],
5073            content_hash: None,
5074        }
5075    }
5076
5077    fn program_only_test_spec(
5078        pdas: BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5079        instructions: Vec<InstructionDef>,
5080    ) -> SerializableStackSpec {
5081        let program_spec = demo_program_spec();
5082        SerializableStackSpec {
5083            ast_version: CURRENT_AST_VERSION.to_string(),
5084            stack_name: "DemoStream".to_string(),
5085            program_ids: vec!["Prog111".to_string()],
5086            idls: vec![program_spec.idl_snapshot.snapshot.clone()],
5087            program_specs: vec![program_spec],
5088            entities: vec![],
5089            pdas,
5090            instructions,
5091            content_hash: None,
5092        }
5093    }
5094
5095    fn test_instruction(amount_hint: Option<InstructionAmountHint>) -> InstructionDef {
5096        InstructionDef {
5097            name: "deposit".to_string(),
5098            discriminator: vec![9],
5099            discriminator_size: 1,
5100            accounts: vec![],
5101            args: amount_hint
5102                .map(|amount_hint| InstructionArgDef {
5103                    name: "amount".to_string(),
5104                    arg_type: "u64".to_string(),
5105                    docs: vec![],
5106                    amount_hint: Some(amount_hint),
5107                })
5108                .into_iter()
5109                .collect(),
5110            errors: vec![],
5111            program_id: Some("Prog111".to_string()),
5112            docs: vec![],
5113        }
5114    }
5115
5116    fn state_key_test_spec(primary_keys: Vec<&str>) -> SerializableStreamSpec {
5117        let round_id = FieldTypeInfo::new("round_id".to_string(), "u64".to_string());
5118        SerializableStreamSpec {
5119            ast_version: CURRENT_AST_VERSION.to_string(),
5120            state_name: "OreRound".to_string(),
5121            program_id: None,
5122            idl: None,
5123            identity: IdentitySpec {
5124                primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
5125                lookup_indexes: vec![],
5126            },
5127            handlers: vec![],
5128            sections: vec![EntitySection {
5129                name: "id".to_string(),
5130                fields: vec![round_id.clone()],
5131                is_nested_struct: false,
5132                parent_field: None,
5133            }],
5134            field_mappings: BTreeMap::from([("id.round_id".to_string(), round_id)]),
5135            resolver_hooks: vec![],
5136            instruction_hooks: vec![],
5137            resolver_specs: vec![],
5138            computed_fields: vec![],
5139            computed_field_specs: vec![],
5140            content_hash: None,
5141            views: vec![],
5142        }
5143    }
5144
5145    fn endpoint_test_spec() -> SerializableStackSpec {
5146        SerializableStackSpec {
5147            ast_version: CURRENT_AST_VERSION.to_string(),
5148            stack_name: "EndpointStream".to_string(),
5149            program_ids: vec![],
5150            idls: vec![],
5151            program_specs: vec![],
5152            entities: vec![state_key_test_spec(vec!["id.round_id"])],
5153            pdas: BTreeMap::new(),
5154            instructions: vec![],
5155            content_hash: None,
5156        }
5157    }
5158
5159    #[test]
5160    fn test_case_conversions() {
5161        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
5162        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
5163    }
5164
5165    #[test]
5166    fn local_stack_codegen_is_endpointless_by_default() {
5167        let output = compile_stack_spec(endpoint_test_spec(), None)
5168            .expect("local stack generation should succeed");
5169
5170        assert!(output.stack_definition.contains(
5171            "  endpoints: {\n    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n  },"
5172        ));
5173    }
5174
5175    #[test]
5176    fn stack_codegen_emits_independent_endpoints_exactly() {
5177        let websocket_url = "wss://stream.example.test/ws/v2?tenant=endpoint";
5178        let http_url = "https://reads.unrelated.test/api/arete/v3";
5179        let output = compile_stack_spec(
5180            endpoint_test_spec(),
5181            Some(TypeScriptStackConfig {
5182                websocket_url: Some(websocket_url.to_string()),
5183                http_url: Some(http_url.to_string()),
5184                ..TypeScriptStackConfig::default()
5185            }),
5186        )
5187        .expect("configured stack generation should succeed");
5188
5189        assert!(output.stack_definition.contains(&format!(
5190            "  endpoints: {{\n    ws: '{}',\n    http: '{}',\n  }},",
5191            websocket_url, http_url
5192        )));
5193        assert!(!output
5194            .stack_definition
5195            .contains("https://stream.example.test/ws/v2"));
5196    }
5197
5198    #[test]
5199    fn explicit_local_websocket_does_not_derive_http() {
5200        let output = compile_stack_spec(
5201            endpoint_test_spec(),
5202            Some(TypeScriptStackConfig {
5203                websocket_url: Some("ws://127.0.0.1:8878/socket".to_string()),
5204                ..TypeScriptStackConfig::default()
5205            }),
5206        )
5207        .expect("configured local stack generation should succeed");
5208
5209        assert!(output
5210            .stack_definition
5211            .contains("    ws: 'ws://127.0.0.1:8878/socket',"));
5212        assert!(output.stack_definition.contains(
5213            "    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
5214        ));
5215        assert!(!output
5216            .stack_definition
5217            .contains("http://127.0.0.1:8878/socket"));
5218    }
5219
5220    #[test]
5221    fn test_normalize_for_comparison() {
5222        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
5223        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
5224        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
5225        assert_eq!(
5226            normalize_for_comparison("admin_set_creator"),
5227            "adminsetcreator"
5228        );
5229        assert_eq!(
5230            normalize_for_comparison("AdminSetCreator"),
5231            "adminsetcreator"
5232        );
5233    }
5234
5235    #[test]
5236    fn test_value_to_typescript_type() {
5237        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
5238        assert_eq!(
5239            value_to_typescript_type(&serde_json::json!("hello")),
5240            "string"
5241        );
5242        assert_eq!(
5243            value_to_typescript_type(&serde_json::json!(true)),
5244            "boolean"
5245        );
5246        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
5247    }
5248
5249    #[test]
5250    fn test_typescript_scalar_array_element() {
5251        assert_eq!(
5252            typescript_scalar_array_element("Vec < f64 >"),
5253            Some("number")
5254        );
5255        assert_eq!(typescript_scalar_array_element("Vec<f32>"), Some("number"));
5256        assert_eq!(typescript_scalar_array_element("f64"), Some("number"));
5257        assert_eq!(
5258            typescript_scalar_array_element("Vec < bool >"),
5259            Some("boolean")
5260        );
5261        assert_eq!(
5262            typescript_scalar_array_element("Vec < String >"),
5263            Some("string")
5264        );
5265        assert_eq!(typescript_scalar_array_element("Vec < u64 >"), None);
5266        assert_eq!(typescript_scalar_array_element("Vec < Pubkey >"), None);
5267    }
5268
5269    #[test]
5270    fn state_view_codegen_emits_exact_key_type_and_deduped_runtime_metadata() {
5271        let output = compile_serializable_spec(
5272            state_key_test_spec(vec!["id.round_id", "id.round_id"]),
5273            "OreRound".to_string(),
5274            None,
5275        )
5276        .expect("duplicate identical keys should compile");
5277
5278        assert!(output.stack_definition.contains(
5279            "export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown>"
5280        ));
5281        assert!(output.stack_definition.contains(
5282            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
5283        ));
5284        assert_eq!(output.stack_definition.matches("['roundId']").count(), 1);
5285    }
5286
5287    #[test]
5288    fn state_view_codegen_rejects_distinct_composite_keys() {
5289        let mut spec = state_key_test_spec(vec!["id.round_id", "id.authority"]);
5290        spec.field_mappings.insert(
5291            "id.authority".to_string(),
5292            FieldTypeInfo::new("authority".to_string(), "String".to_string()),
5293        );
5294
5295        let error = compile_serializable_spec(spec, "OreRound".to_string(), None)
5296            .expect_err("distinct composite keys must fail generation");
5297
5298        assert!(error.contains("does not support composite state keys"));
5299        assert!(error.contains("id.round_id, id.authority"));
5300    }
5301
5302    #[test]
5303    fn pda_imports_follow_emitted_seed_variants() {
5304        let empty_seed_pdas = BTreeMap::from([(
5305            "demo".to_string(),
5306            BTreeMap::from([(
5307                "singleton".to_string(),
5308                PdaDefinition {
5309                    name: "singleton".to_string(),
5310                    seeds: vec![],
5311                    program_id: None,
5312                },
5313            )]),
5314        )]);
5315        let output = compile_program_modules(program_only_test_spec(empty_seed_pdas, vec![]), None)
5316            .expect("empty-seed PDA generation should succeed");
5317        assert_eq!(
5318            output.imports,
5319            "import { z } from 'zod';\nimport { pda } from '@usearete/sdk';"
5320        );
5321        assert!(output
5322            .stack_definition
5323            .contains("singleton: pda('Prog111'),"));
5324        assert!(!output.stack_definition.contains("pda('Prog111', )"));
5325
5326        let literal_account_pdas = BTreeMap::from([(
5327            "demo".to_string(),
5328            BTreeMap::from([(
5329                "vault".to_string(),
5330                PdaDefinition {
5331                    name: "vault".to_string(),
5332                    seeds: vec![
5333                        PdaSeedDef::Literal {
5334                            value: "vault".to_string(),
5335                        },
5336                        PdaSeedDef::AccountRef {
5337                            account_name: "authority".to_string(),
5338                        },
5339                    ],
5340                    program_id: None,
5341                },
5342            )]),
5343        )]);
5344        let output =
5345            compile_program_modules(program_only_test_spec(literal_account_pdas, vec![]), None)
5346                .expect("literal/account PDA generation should succeed");
5347        assert_eq!(
5348            output.imports,
5349            "import { z } from 'zod';\nimport { pda, literal, account } from '@usearete/sdk';"
5350        );
5351
5352        let arg_bytes_pdas = BTreeMap::from([(
5353            "demo".to_string(),
5354            BTreeMap::from([(
5355                "position".to_string(),
5356                PdaDefinition {
5357                    name: "position".to_string(),
5358                    seeds: vec![
5359                        PdaSeedDef::ArgRef {
5360                            arg_name: "roundId".to_string(),
5361                            arg_type: Some("u64".to_string()),
5362                        },
5363                        PdaSeedDef::Bytes {
5364                            value: vec![0, 255],
5365                        },
5366                    ],
5367                    program_id: None,
5368                },
5369            )]),
5370        )]);
5371        let output = compile_program_modules(program_only_test_spec(arg_bytes_pdas, vec![]), None)
5372            .expect("arg/bytes PDA generation should succeed");
5373        assert_eq!(
5374            output.imports,
5375            "import { z } from 'zod';\nimport { pda, arg, bytes } from '@usearete/sdk';"
5376        );
5377    }
5378
5379    #[test]
5380    fn program_modules_emit_configured_sdk_definition_hash() {
5381        let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5382        let mut program = TypeScriptProgramConfig::from(
5383            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5384        );
5385        program.definition.sdk_definition_hash = Some("definition-v1".to_string());
5386        let output = compile_program_modules(
5387            stack_spec,
5388            Some(TypeScriptStackConfig {
5389                programs: Some(vec![program]),
5390                ..TypeScriptStackConfig::default()
5391            }),
5392        )
5393        .expect("program definition generation should succeed");
5394
5395        assert!(output
5396            .stack_definition
5397            .contains("sdkDefinitionHash: 'definition-v1',"));
5398    }
5399
5400    #[test]
5401    fn program_modules_separate_portable_definition_from_release_reference() {
5402        let mut stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5403        let mut local = TypeScriptProgramConfig::from(
5404            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5405        );
5406        local.definition.sdk_definition_hash = Some("portable-definition".to_string());
5407        let hosted_release = "arete:h1:program-release:sha256:hosted";
5408        let output = compile_program_modules(
5409            stack_spec.clone(),
5410            Some(TypeScriptStackConfig {
5411                programs: Some(vec![TypeScriptProgramConfig {
5412                    release: TypeScriptProgramReleaseReference {
5413                        program_release_hash: hosted_release.to_string(),
5414                        program_spec_hash: local.definition.program_spec_hash.clone(),
5415                    },
5416                    ..local.clone()
5417                }]),
5418                ..TypeScriptStackConfig::default()
5419            }),
5420        )
5421        .expect("program definition generation should succeed");
5422        let definition = output.stack_definition;
5423
5424        assert!(definition.contains("sdkDefinitionHash: 'portable-definition',"));
5425        assert!(definition.contains(&format!(
5426            "programSpecHash: '{}',",
5427            local.definition.program_spec_hash
5428        )));
5429        let programs = definition
5430            .split("/** Release and explicit read transport")
5431            .next()
5432            .unwrap();
5433        assert!(!programs.contains("programReleaseHash"));
5434        assert!(!programs.contains("decoderEngineId"));
5435        assert!(definition.contains(&format!("programReleaseHash: \"{hosted_release}\"")));
5436        assert!(!definition.contains("decoderEngineId"));
5437        stack_spec.program_specs.clear();
5438        assert!(compile_program_modules(stack_spec, None)
5439            .unwrap_err()
5440            .contains("Regenerate the .stack.json"));
5441    }
5442
5443    #[test]
5444    fn program_account_codegen_is_semantic_and_release_lives_in_program_reads() {
5445        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5446            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5447            None,
5448        )
5449        .expect("fixture identity");
5450        let stack_spec =
5451            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5452        let output = compile_program_modules(stack_spec, None).expect("program SDK generation");
5453
5454        assert!(output.stack_definition.contains(
5455            "programAccountRead<Presale>({ account: 'Presale', schema: PresaleSchema })"
5456        ));
5457        assert!(!output.stack_definition.contains("path:"));
5458        assert!(output.stack_definition.contains(&format!(
5459            "programReleaseHash: \"{}\"",
5460            identity.release_hash
5461        )));
5462        assert!(output
5463            .stack_definition
5464            .contains("transport: { kind: 'local-http', endpointSource: 'connect-http-url' }"));
5465        assert!(!output.stack_definition.contains("path:"));
5466    }
5467
5468    #[test]
5469    fn legacy_account_reader_ast_without_exact_program_specs_fails_closed() {
5470        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5471            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5472            None,
5473        )
5474        .expect("fixture identity");
5475        let mut stack_spec =
5476            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5477        assert!(!stack_spec.idls[0].accounts.is_empty());
5478        stack_spec.program_specs.clear();
5479
5480        let error = compile_program_modules(stack_spec, None).unwrap_err();
5481        assert!(error.contains("no exact public ProgramSpecV1 values"));
5482        assert!(error.contains("Regenerate the .stack.json"));
5483    }
5484
5485    #[test]
5486    fn hosted_program_configs_preserve_order_releases_endpoints_and_auth() {
5487        let stack_spec = two_program_test_spec();
5488        let programs = stack_spec
5489            .program_specs
5490            .iter()
5491            .enumerate()
5492            .map(|(index, spec)| {
5493                let identity = arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap();
5494                let mut config = TypeScriptProgramConfig::from(&identity);
5495                config.release.program_release_hash = format!("hosted-release-{index}");
5496                let binding_id = format!("prb_{index:032}");
5497                config.transport =
5498                    TypeScriptProgramReadTransport::HostedBinding(TypeScriptProgramReadBinding {
5499                        endpoint: format!("https://reads.example.test/exact/{index}/"),
5500                        program_read_binding_id: binding_id.clone(),
5501                        auth: serde_json::json!({
5502                            "targetKind": "program-read-binding",
5503                            "targetId": binding_id,
5504                            "sessionEndpoint": format!("https://auth.example.test/{index}"),
5505                            "index": index
5506                        }),
5507                    });
5508                config
5509            })
5510            .collect::<Vec<_>>();
5511
5512        let output = compile_stack_spec(
5513            stack_spec,
5514            Some(TypeScriptStackConfig {
5515                programs: Some(programs),
5516                ..Default::default()
5517            }),
5518        )
5519        .expect("ordered hosted descriptors should compile");
5520        let generated = output.stack_definition;
5521        let portable = generated
5522            .split("  programs: {")
5523            .nth(1)
5524            .expect("portable programs block")
5525            .split("  programReads: {")
5526            .next()
5527            .expect("portable programs block end");
5528        let reads = generated
5529            .split("  programReads: {")
5530            .nth(1)
5531            .expect("parallel program reads block");
5532
5533        assert!(portable.find("secondProgram:").unwrap() < portable.find("firstProgram:").unwrap());
5534        assert!(!portable.contains("programReleaseHash"));
5535        assert!(!portable.contains("endpoint"));
5536        assert!(reads.find("secondProgram:").unwrap() < reads.find("firstProgram:").unwrap());
5537        assert!(reads.contains("programReleaseHash: \"hosted-release-0\""));
5538        assert!(reads.contains("programReleaseHash: \"hosted-release-1\""));
5539        assert!(reads.contains("kind: 'hosted-binding'"));
5540        assert!(reads.contains("endpoint: \"https://reads.example.test/exact/0/\""));
5541        assert!(reads.contains("programReadBindingId: \"prb_00000000000000000000000000000001\""));
5542        assert!(reads.contains(
5543            "auth: {\"index\":0,\"sessionEndpoint\":\"https://auth.example.test/0\",\"targetId\":\"prb_00000000000000000000000000000000\",\"targetKind\":\"program-read-binding\"}"
5544        ));
5545    }
5546
5547    #[test]
5548    fn hosted_program_config_mismatches_fail_by_index_without_name_fallback() {
5549        let stack_spec = two_program_test_spec();
5550        let local = stack_spec
5551            .program_specs
5552            .iter()
5553            .map(|spec| {
5554                TypeScriptProgramConfig::from(
5555                    &arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap(),
5556                )
5557            })
5558            .collect::<Vec<_>>();
5559
5560        let count_error = compile_program_modules(
5561            stack_spec.clone(),
5562            Some(TypeScriptStackConfig {
5563                programs: Some(vec![local[0].clone()]),
5564                ..Default::default()
5565            }),
5566        )
5567        .unwrap_err();
5568        assert!(count_error.contains("descriptor count mismatch"));
5569
5570        let mut swapped = local.clone();
5571        swapped.swap(0, 1);
5572        let order_error = compile_program_modules(
5573            stack_spec.clone(),
5574            Some(TypeScriptStackConfig {
5575                programs: Some(swapped),
5576                ..Default::default()
5577            }),
5578        )
5579        .unwrap_err();
5580        assert!(order_error.contains("program ID mismatch at index 0"));
5581
5582        let mut bad_hash = local;
5583        bad_hash[1].definition.program_spec_hash = "wrong-spec".to_string();
5584        let hash_error = compile_program_modules(
5585            stack_spec,
5586            Some(TypeScriptStackConfig {
5587                programs: Some(bad_hash),
5588                ..Default::default()
5589            }),
5590        )
5591        .unwrap_err();
5592        assert!(hash_error.contains("programSpecHash mismatch at index 1"));
5593    }
5594
5595    #[test]
5596    fn raw_operations_omit_semantic_only_types_and_context() {
5597        let output = compile_program_modules(
5598            program_only_test_spec(BTreeMap::new(), vec![test_instruction(None)]),
5599            None,
5600        )
5601        .expect("raw operation generation should succeed");
5602        let file = output.full_file();
5603
5604        assert!(!output.imports.contains("BuildOptions"));
5605        assert!(!output.imports.contains("ProgramOperationContext"));
5606        assert!(file.contains("createOperations()"));
5607        assert!(!file.contains("build?: BuildOptions;"));
5608    }
5609
5610    #[test]
5611    fn context_free_amount_operations_import_build_options_only() {
5612        let output = compile_program_modules(
5613            program_only_test_spec(
5614                BTreeMap::new(),
5615                vec![test_instruction(Some(InstructionAmountHint {
5616                    decimals_source: AmountDecimalsSource::Constant { decimals: 9 },
5617                }))],
5618            ),
5619            None,
5620        )
5621        .expect("constant amount operation generation should succeed");
5622        let file = output.full_file();
5623
5624        assert!(output.imports.contains("type BuildOptions"));
5625        assert!(output.imports.contains("toRawAmount"));
5626        assert!(!output.imports.contains("ProgramOperationContext"));
5627        assert!(file.contains("build?: BuildOptions;"));
5628        assert!(file.contains("createOperations()"));
5629        assert!(!file.contains("context.chain"));
5630    }
5631
5632    #[test]
5633    fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
5634        let spec = SerializableStreamSpec {
5635            ast_version: CURRENT_AST_VERSION.to_string(),
5636            state_name: "TokenPosition".to_string(),
5637            program_id: None,
5638            idl: None,
5639            identity: IdentitySpec {
5640                primary_keys: vec!["id.address".to_string()],
5641                lookup_indexes: vec![],
5642            },
5643            handlers: vec![],
5644            sections: vec![
5645                EntitySection {
5646                    name: "root".to_string(),
5647                    fields: vec![FieldTypeInfo::new(
5648                        "total_deposit".to_string(),
5649                        "u64".to_string(),
5650                    )],
5651                    is_nested_struct: false,
5652                    parent_field: None,
5653                },
5654                EntitySection {
5655                    name: "metrics".to_string(),
5656                    fields: vec![FieldTypeInfo::new(
5657                        "last_updated_at".to_string(),
5658                        "i64".to_string(),
5659                    )],
5660                    is_nested_struct: false,
5661                    parent_field: None,
5662                },
5663            ],
5664            field_mappings: BTreeMap::new(),
5665            resolver_hooks: vec![],
5666            resolver_specs: vec![],
5667            instruction_hooks: vec![],
5668            computed_fields: vec![],
5669            computed_field_specs: vec![],
5670            content_hash: None,
5671            views: vec![],
5672        };
5673
5674        let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
5675            .expect("should compile");
5676        let file = output.full_file();
5677
5678        assert!(
5679            file.contains("export interface TokenPosition {"),
5680            "missing main interface:\n{}",
5681            file
5682        );
5683        assert!(
5684            file.contains("totalDeposit: bigint;"),
5685            "missing root canonical field:\n{}",
5686            file
5687        );
5688        assert!(
5689            file.contains("metrics: TokenPositionMetrics;"),
5690            "missing nested canonical section field:\n{}",
5691            file
5692        );
5693        assert!(
5694            file.contains("export interface TokenPositionMetrics {"),
5695            "missing nested interface:\n{}",
5696            file
5697        );
5698        assert!(
5699            file.contains("lastUpdatedAt: bigint;"),
5700            "missing nested canonical field:\n{}",
5701            file
5702        );
5703
5704        let bigint_schema = bigint_zod();
5705        assert!(
5706            file.contains("export const TokenPositionSchema = z.object({"),
5707            "missing main schema:\n{}",
5708            file
5709        );
5710        assert!(
5711            file.contains(&format!("total_deposit: {},", bigint_schema)),
5712            "missing raw root schema field:\n{}",
5713            file
5714        );
5715        assert!(
5716            file.contains("metrics: TokenPositionMetricsSchema,"),
5717            "missing nested schema ref:\n{}",
5718            file
5719        );
5720        assert!(
5721            file.contains("totalDeposit: value.total_deposit,"),
5722            "missing root transform:\n{}",
5723            file
5724        );
5725        assert!(
5726            file.contains("metrics: value.metrics,"),
5727            "missing nested transform:\n{}",
5728            file
5729        );
5730
5731        assert!(
5732            file.contains("export const TokenPositionMetricsSchema = z.object({"),
5733            "missing nested schema:\n{}",
5734            file
5735        );
5736        assert!(
5737            file.contains(&format!("last_updated_at: {},", bigint_schema)),
5738            "missing raw nested schema field:\n{}",
5739            file
5740        );
5741        assert!(
5742            file.contains("lastUpdatedAt: value.last_updated_at,"),
5743            "missing nested transform:\n{}",
5744            file
5745        );
5746
5747        assert!(
5748            file.contains("export const TokenPositionPatchSchema = z.object({"),
5749            "missing patch schema:\n{}",
5750            file
5751        );
5752        assert!(
5753            file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
5754            "missing sparse patch field:\n{}",
5755            file
5756        );
5757        assert!(
5758            file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
5759            "missing nested patch schema ref:\n{}",
5760            file
5761        );
5762        assert!(
5763            file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
5764            "missing sparse patch transform:\n{}",
5765            file
5766        );
5767        assert!(
5768            file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
5769            "missing nested patch schema:\n{}",
5770            file
5771        );
5772        assert!(
5773            file.contains("patchSchemas: {\n    TokenPosition: TokenPositionPatchSchema,"),
5774            "missing stack patch schema map:\n{}",
5775            file
5776        );
5777    }
5778
5779    #[test]
5780    fn captured_accounts_keep_the_runtime_envelope_and_full_inner_schema() {
5781        let miner_snapshot = FieldTypeInfo {
5782            field_name: "miner_snapshot".to_string(),
5783            raw_name: Some("miner_snapshot".to_string()),
5784            canonical_name: Some("minerSnapshot".to_string()),
5785            rust_type_name: "Option<Miner>".to_string(),
5786            base_type: BaseType::Object,
5787            integer_kind: None,
5788            is_optional: true,
5789            is_array: false,
5790            inner_type: Some("Miner".to_string()),
5791            source_path: None,
5792            resolved_type: Some(ResolvedStructType {
5793                type_name: "Miner".to_string(),
5794                fields: vec![
5795                    ResolvedField {
5796                        field_name: "deployed".to_string(),
5797                        raw_name: Some("deployed".to_string()),
5798                        canonical_name: Some("deployed".to_string()),
5799                        field_type: "u64".to_string(),
5800                        base_type: BaseType::Integer,
5801                        integer_kind: Some(IntegerKind::U64),
5802                        is_optional: false,
5803                        is_array: true,
5804                    },
5805                    ResolvedField {
5806                        field_name: "round_id".to_string(),
5807                        raw_name: Some("round_id".to_string()),
5808                        canonical_name: Some("roundId".to_string()),
5809                        field_type: "u64".to_string(),
5810                        base_type: BaseType::Integer,
5811                        integer_kind: Some(IntegerKind::U64),
5812                        is_optional: false,
5813                        is_array: false,
5814                    },
5815                ],
5816                is_instruction: false,
5817                is_account: true,
5818                is_event: false,
5819                is_enum: false,
5820                enum_variants: vec![],
5821            }),
5822            emit: true,
5823        };
5824        let spec = SerializableStreamSpec {
5825            ast_version: CURRENT_AST_VERSION.to_string(),
5826            state_name: "OreMiner".to_string(),
5827            program_id: None,
5828            idl: None,
5829            identity: IdentitySpec {
5830                primary_keys: vec!["id.authority".to_string()],
5831                lookup_indexes: vec![],
5832            },
5833            handlers: vec![SerializableHandlerSpec {
5834                source: SourceSpec::Source {
5835                    program_id: None,
5836                    discriminator: None,
5837                    type_name: "Miner".to_string(),
5838                    serialization: None,
5839                    is_account: true,
5840                },
5841                key_resolution: KeyResolutionStrategy::Embedded {
5842                    primary_field: FieldPath::new(&["authority"]),
5843                },
5844                mappings: vec![SerializableFieldMapping {
5845                    target_path: "miner_snapshot".to_string(),
5846                    source: MappingSource::AsCapture {
5847                        field_transforms: BTreeMap::new(),
5848                    },
5849                    transform: None,
5850                    population: PopulationStrategy::LastWrite,
5851                    condition: None,
5852                    when: None,
5853                    stop: None,
5854                    emit: true,
5855                }],
5856                conditions: vec![],
5857                emit: true,
5858            }],
5859            sections: vec![
5860                EntitySection {
5861                    name: "id".to_string(),
5862                    fields: vec![FieldTypeInfo::new(
5863                        "authority".to_string(),
5864                        "String".to_string(),
5865                    )],
5866                    is_nested_struct: false,
5867                    parent_field: None,
5868                },
5869                EntitySection {
5870                    name: "root".to_string(),
5871                    fields: vec![miner_snapshot],
5872                    is_nested_struct: false,
5873                    parent_field: None,
5874                },
5875            ],
5876            field_mappings: BTreeMap::new(),
5877            resolver_hooks: vec![],
5878            resolver_specs: vec![],
5879            instruction_hooks: vec![],
5880            computed_fields: vec![],
5881            computed_field_specs: vec![],
5882            content_hash: None,
5883            views: vec![],
5884        };
5885
5886        let output = compile_serializable_spec(spec, "OreMiner".to_string(), None)
5887            .expect("captured account generation should succeed");
5888        let file = output.full_file();
5889
5890        assert!(file.contains("minerSnapshot: CaptureWrapper<Miner> | null;"));
5891        assert!(file.contains("export interface CaptureWrapper<T> {"));
5892        assert!(file.contains("accountAddress: string;"));
5893        assert!(file.contains("account_address: z.string(),"));
5894        assert!(file.contains("accountAddress: value.account_address,"));
5895        assert!(file
5896            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5897        assert!(file
5898            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5899        assert!(!file.contains("CaptureWrapperSchema(MinerPatchSchema)"));
5900    }
5901
5902    #[test]
5903    fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
5904        let spec = SerializableStreamSpec {
5905            ast_version: CURRENT_AST_VERSION.to_string(),
5906            state_name: "TokenHolder".to_string(),
5907            program_id: None,
5908            idl: None,
5909            identity: IdentitySpec {
5910                primary_keys: vec!["id.address".to_string()],
5911                lookup_indexes: vec![],
5912            },
5913            handlers: vec![],
5914            sections: vec![EntitySection {
5915                name: "root".to_string(),
5916                fields: vec![FieldTypeInfo {
5917                    field_name: "base_token_metadata".to_string(),
5918                    raw_name: Some("base_token_metadata".to_string()),
5919                    canonical_name: Some("baseTokenMetadata".to_string()),
5920                    rust_type_name: "Option<TokenMetadata>".to_string(),
5921                    base_type: BaseType::Object,
5922                    integer_kind: None,
5923                    is_optional: true,
5924                    is_array: false,
5925                    inner_type: Some("TokenMetadata".to_string()),
5926                    source_path: None,
5927                    resolved_type: None,
5928                    emit: true,
5929                }],
5930                is_nested_struct: false,
5931                parent_field: None,
5932            }],
5933            field_mappings: BTreeMap::new(),
5934            resolver_hooks: vec![],
5935            resolver_specs: vec![],
5936            instruction_hooks: vec![],
5937            computed_fields: vec![],
5938            computed_field_specs: vec![],
5939            content_hash: None,
5940            views: vec![],
5941        };
5942
5943        let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
5944            .expect("should compile");
5945        let file = output.full_file();
5946
5947        assert!(
5948            file.contains("export interface TokenMetadata {"),
5949            "missing builtin interface:\n{}",
5950            file
5951        );
5952        assert!(
5953            file.contains("logoUri?: string | null;"),
5954            "missing canonical builtin field:\n{}",
5955            file
5956        );
5957        assert!(
5958            file.contains("export const TokenMetadataSchema = z.object({"),
5959            "missing builtin schema:\n{}",
5960            file
5961        );
5962        assert!(
5963            file.contains("logo_uri: z.string().nullable().optional(),"),
5964            "missing raw builtin input field:\n{}",
5965            file
5966        );
5967        assert!(
5968            file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
5969            "missing canonical builtin transform:\n{}",
5970            file
5971        );
5972        assert!(
5973            file.contains("export const TokenMetadataPatchSchema = z.object({"),
5974            "missing builtin patch schema:\n{}",
5975            file
5976        );
5977        assert!(
5978            file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
5979            "missing patch schema usage for builtin field:\n{}",
5980            file
5981        );
5982    }
5983
5984    #[test]
5985    fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
5986        let spec = SerializableStreamSpec {
5987            ast_version: CURRENT_AST_VERSION.to_string(),
5988            state_name: "OreRound".to_string(),
5989            program_id: None,
5990            idl: None,
5991            identity: IdentitySpec {
5992                primary_keys: vec!["id.round_id".to_string()],
5993                lookup_indexes: vec![],
5994            },
5995            handlers: vec![],
5996            sections: vec![EntitySection {
5997                name: "results".to_string(),
5998                fields: vec![FieldTypeInfo {
5999                    field_name: "results.expires_at_slot_hash".to_string(),
6000                    raw_name: Some("results.expires_at_slot_hash".to_string()),
6001                    canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
6002                    rust_type_name: "Option<String>".to_string(),
6003                    base_type: BaseType::String,
6004                    integer_kind: None,
6005                    is_optional: true,
6006                    is_array: false,
6007                    inner_type: Some("String".to_string()),
6008                    source_path: None,
6009                    resolved_type: None,
6010                    emit: true,
6011                }],
6012                is_nested_struct: false,
6013                parent_field: None,
6014            }],
6015            field_mappings: BTreeMap::new(),
6016            resolver_hooks: vec![],
6017            resolver_specs: vec![],
6018            instruction_hooks: vec![],
6019            computed_fields: vec![],
6020            computed_field_specs: vec![],
6021            content_hash: None,
6022            views: vec![],
6023        };
6024
6025        let output =
6026            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6027        let file = output.full_file();
6028
6029        assert!(
6030            file.contains("export interface OreRoundResults {"),
6031            "missing section interface:\n{}",
6032            file
6033        );
6034        assert!(
6035            file.contains("expiresAtSlotHash: string | null;"),
6036            "missing localized canonical field:\n{}",
6037            file
6038        );
6039        assert!(
6040            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6041            "missing localized canonical schema field:\n{}",
6042            file
6043        );
6044        assert!(
6045            file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
6046            "missing localized transform:\n{}",
6047            file
6048        );
6049        assert!(
6050            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6051            "missing localized patch schema field:\n{}",
6052            file
6053        );
6054        assert!(
6055            file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
6056            "missing localized sparse transform:\n{}",
6057            file
6058        );
6059    }
6060
6061    #[test]
6062    fn test_streamed_completed_schema_allows_unwritten_nullable_fields() {
6063        let mut optional_count = FieldTypeInfo::new("count".to_string(), "u64".to_string());
6064        optional_count.is_optional = true;
6065        let spec = SerializableStreamSpec {
6066            ast_version: CURRENT_AST_VERSION.to_string(),
6067            state_name: "OreRound".to_string(),
6068            program_id: None,
6069            idl: None,
6070            identity: IdentitySpec {
6071                primary_keys: vec!["id.round_id".to_string()],
6072                lookup_indexes: vec![],
6073            },
6074            handlers: vec![],
6075            sections: vec![EntitySection {
6076                name: "state".to_string(),
6077                fields: vec![optional_count],
6078                is_nested_struct: false,
6079                parent_field: None,
6080            }],
6081            field_mappings: BTreeMap::new(),
6082            resolver_hooks: vec![],
6083            resolver_specs: vec![],
6084            instruction_hooks: vec![],
6085            computed_fields: vec![],
6086            computed_field_specs: vec![],
6087            content_hash: None,
6088            views: vec![],
6089        };
6090
6091        let output =
6092            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6093        let file = output.full_file();
6094        assert!(
6095            file.contains("count: z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value)).nullable().optional(),"),
6096            "completed schema should allow absent nullable fields:\n{}",
6097            file
6098        );
6099    }
6100
6101    #[test]
6102    fn test_derived_view_codegen() {
6103        let spec = SerializableStreamSpec {
6104            ast_version: CURRENT_AST_VERSION.to_string(),
6105            state_name: "OreRound".to_string(),
6106            program_id: None,
6107            idl: None,
6108            identity: IdentitySpec {
6109                primary_keys: vec!["id".to_string()],
6110                lookup_indexes: vec![],
6111            },
6112            handlers: vec![],
6113            sections: vec![],
6114            field_mappings: BTreeMap::new(),
6115            resolver_hooks: vec![],
6116            resolver_specs: vec![],
6117            instruction_hooks: vec![],
6118            computed_fields: vec![],
6119            computed_field_specs: vec![],
6120            content_hash: None,
6121            views: vec![
6122                ViewDef {
6123                    id: "OreRound/latest".to_string(),
6124                    source: ViewSource::Entity {
6125                        name: "OreRound".to_string(),
6126                    },
6127                    pipeline: vec![ViewTransform::Last],
6128                    output: ViewOutput::Single,
6129                },
6130                ViewDef {
6131                    id: "OreRound/top10".to_string(),
6132                    source: ViewSource::Entity {
6133                        name: "OreRound".to_string(),
6134                    },
6135                    pipeline: vec![ViewTransform::Take { count: 10 }],
6136                    output: ViewOutput::Collection,
6137                },
6138            ],
6139        };
6140
6141        let output =
6142            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6143
6144        let stack_def = &output.stack_definition;
6145
6146        assert!(
6147            stack_def.contains("listView<OreRound>('OreRound/latest')"),
6148            "Expected 'latest' derived view using listView, got:\n{}",
6149            stack_def
6150        );
6151        assert!(
6152            stack_def.contains("listView<OreRound>('OreRound/top10')"),
6153            "Expected 'top10' derived view using listView, got:\n{}",
6154            stack_def
6155        );
6156        assert!(
6157            stack_def.contains("latest:"),
6158            "Expected 'latest' key, got:\n{}",
6159            stack_def
6160        );
6161        assert!(
6162            stack_def.contains("top10:"),
6163            "Expected 'top10' key, got:\n{}",
6164            stack_def
6165        );
6166        assert!(
6167            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
6168            "Expected listView helper function, got:\n{}",
6169            stack_def
6170        );
6171    }
6172
6173    #[test]
6174    fn test_account_type_collision_uses_account_suffix() {
6175        let plan_field = FieldTypeInfo {
6176            field_name: "plan".to_string(),
6177            raw_name: Some("plan".to_string()),
6178            canonical_name: Some("plan".to_string()),
6179            rust_type_name: "Option<serde_json::Value>".to_string(),
6180            base_type: BaseType::Object,
6181            integer_kind: None,
6182            is_optional: false,
6183            is_array: false,
6184            inner_type: Some("Value".to_string()),
6185            source_path: None,
6186            resolved_type: Some(ResolvedStructType {
6187                type_name: "plan".to_string(),
6188                fields: vec![],
6189                is_instruction: false,
6190                is_account: true,
6191                is_event: false,
6192                is_enum: false,
6193                enum_variants: vec![],
6194            }),
6195            emit: true,
6196        };
6197
6198        let spec = SerializableStreamSpec {
6199            ast_version: CURRENT_AST_VERSION.to_string(),
6200            state_name: "Plan".to_string(),
6201            program_id: None,
6202            idl: None,
6203            identity: IdentitySpec {
6204                primary_keys: vec!["id.address".to_string()],
6205                lookup_indexes: vec![],
6206            },
6207            handlers: vec![],
6208            sections: vec![
6209                EntitySection {
6210                    name: "id".to_string(),
6211                    fields: vec![FieldTypeInfo::new(
6212                        "address".to_string(),
6213                        "String".to_string(),
6214                    )],
6215                    is_nested_struct: false,
6216                    parent_field: None,
6217                },
6218                EntitySection {
6219                    name: "plan".to_string(),
6220                    fields: vec![plan_field],
6221                    is_nested_struct: false,
6222                    parent_field: None,
6223                },
6224            ],
6225            field_mappings: BTreeMap::new(),
6226            resolver_hooks: vec![],
6227            instruction_hooks: vec![],
6228            resolver_specs: vec![],
6229            computed_fields: vec![],
6230            computed_field_specs: vec![],
6231            content_hash: None,
6232            views: vec![],
6233        };
6234
6235        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
6236            .expect("typescript sdk generation should succeed");
6237
6238        assert!(
6239            output.interfaces.contains("export interface PlanPlan {"),
6240            "expected PlanPlan section interface, got:\n{}",
6241            output.interfaces
6242        );
6243        assert!(
6244            output.interfaces.contains("plan: PlanAccount;"),
6245            "expected PlanAccount field reference, got:\n{}",
6246            output.interfaces
6247        );
6248        assert!(
6249            output.interfaces.contains("export interface PlanAccount {"),
6250            "expected PlanAccount interface, got:\n{}",
6251            output.interfaces
6252        );
6253    }
6254
6255    #[test]
6256    fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
6257        let shared_idl = serde_json::json!({
6258            "name": "subscriptions",
6259            "version": "0.1.0",
6260            "accounts": [],
6261            "instructions": [],
6262            "types": [
6263                {
6264                    "name": "planStatus",
6265                    "type": {
6266                        "kind": "enum",
6267                        "variants": [{ "name": "sunset" }, { "name": "active" }]
6268                    }
6269                }
6270            ],
6271            "events": [],
6272            "errors": [],
6273            "discriminant_size": 8
6274        });
6275
6276        let idl_snapshot: IdlSnapshot =
6277            serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
6278
6279        let make_entity = |name: &str| SerializableStreamSpec {
6280            ast_version: CURRENT_AST_VERSION.to_string(),
6281            state_name: name.to_string(),
6282            program_id: None,
6283            idl: None,
6284            identity: IdentitySpec {
6285                primary_keys: vec!["id.address".to_string()],
6286                lookup_indexes: vec![],
6287            },
6288            handlers: vec![],
6289            sections: vec![EntitySection {
6290                name: "id".to_string(),
6291                fields: vec![FieldTypeInfo::new(
6292                    "address".to_string(),
6293                    "String".to_string(),
6294                )],
6295                is_nested_struct: false,
6296                parent_field: None,
6297            }],
6298            field_mappings: BTreeMap::new(),
6299            resolver_hooks: vec![],
6300            instruction_hooks: vec![],
6301            resolver_specs: vec![],
6302            computed_fields: vec![],
6303            computed_field_specs: vec![],
6304            content_hash: None,
6305            views: vec![],
6306        };
6307
6308        let stack_spec = SerializableStackSpec {
6309            ast_version: CURRENT_AST_VERSION.to_string(),
6310            stack_name: "Subscriptions".to_string(),
6311            program_ids: vec![],
6312            idls: vec![idl_snapshot],
6313            program_specs: vec![],
6314            entities: vec![make_entity("Plan"), make_entity("Subscription")],
6315            pdas: BTreeMap::new(),
6316            instructions: vec![],
6317            content_hash: None,
6318        };
6319
6320        let output =
6321            compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
6322        let file = output.full_file();
6323        let count = output
6324            .interfaces
6325            .matches("export type PlanStatus =")
6326            .count();
6327
6328        assert_eq!(
6329            count, 1,
6330            "expected shared enum type to be emitted once, got:\n{}",
6331            output.interfaces
6332        );
6333        assert!(
6334            file.contains("_STACK_CORE = {"),
6335            "core export missing:\n{}",
6336            file
6337        );
6338        assert!(
6339            !file.contains("extendStack"),
6340            "no extension wiring expected:\n{}",
6341            file
6342        );
6343    }
6344
6345    #[test]
6346    fn golden_ore_stack_json_compiles_program_modules_without_entities() {
6347        let path = concat!(
6348            env!("CARGO_MANIFEST_DIR"),
6349            "/../stacks/ore/.arete/OreStream.stack.json"
6350        );
6351        let json = match std::fs::read_to_string(path) {
6352            Ok(c) => c,
6353            // Stack JSON is generated by the macro build; skip if not present.
6354            Err(_) => return,
6355        };
6356        let mut spec: SerializableStackSpec =
6357            serde_json::from_str(&json).expect("ore stack json should deserialize");
6358
6359        if spec.program_specs.is_empty() {
6360            let error = compile_program_modules(spec, None).unwrap_err();
6361            assert!(error.contains("Regenerate the .stack.json"));
6362            return;
6363        }
6364
6365        // Program-only emission must not depend on entities at all.
6366        spec.entities.clear();
6367
6368        let output =
6369            compile_program_modules(spec, None).expect("program-module compilation should succeed");
6370        let file = output.full_file();
6371
6372        // Standalone per-program consts plus the combined map.
6373        assert!(
6374            file.contains("export const ORE = {"),
6375            "ore const missing:\n{}",
6376            file
6377        );
6378        assert!(
6379            file.contains("export const ENTROPY = {"),
6380            "entropy const missing"
6381        );
6382        assert!(
6383            file.contains("export const ORE_STREAM_PROGRAMS = {"),
6384            "combined program map missing"
6385        );
6386        assert!(file.contains("  ore: ORE,"));
6387        assert!(file.contains("  entropy: ENTROPY,"));
6388        assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
6389        assert!(file.contains("All portable programs from the OreStream stack"));
6390        assert!(file.contains("export const ORE_STREAM_PROGRAM_READS = {"));
6391
6392        // Program bodies keep the full SDK surface...
6393        assert!(file.contains("createInstructionHandler"));
6394        assert!(file.contains("pdas: {"));
6395        assert!(file.contains("addresses: {"));
6396        assert!(file.contains("instructions: {"));
6397        assert!(file.contains("createPreparedInstruction({"));
6398        assert!(file.contains("buildInstruction("));
6399
6400        // ...but nothing stack- or view-shaped is emitted.
6401        assert!(!file.contains("stateView"), "no view helpers expected");
6402        assert!(!file.contains("listView"), "no view helpers expected");
6403        assert!(!file.contains("views:"), "no views block expected");
6404        assert!(!file.contains("endpoints:"), "no endpoints block expected");
6405        assert!(
6406            !file.contains("extendStack"),
6407            "no extension wiring expected"
6408        );
6409    }
6410
6411    #[test]
6412    fn golden_ore_stack_json_emits_typed_state_view_keys() {
6413        let path = concat!(
6414            env!("CARGO_MANIFEST_DIR"),
6415            "/../stacks/ore/.arete/OreStream.stack.json"
6416        );
6417        let json = match std::fs::read_to_string(path) {
6418            Ok(contents) => contents,
6419            // Stack JSON is generated by the macro build; skip if not present.
6420            Err(_) => return,
6421        };
6422        let spec: SerializableStackSpec =
6423            serde_json::from_str(&json).expect("ore stack json should deserialize");
6424
6425        if spec.program_specs.is_empty() {
6426            let error = compile_stack_spec(spec, None).unwrap_err();
6427            assert!(error.contains("Regenerate the .stack.json"));
6428            return;
6429        }
6430
6431        let output = compile_stack_spec(spec, None).expect("ore stack should compile");
6432        let stack = output.stack_definition;
6433
6434        assert!(stack.contains(
6435            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
6436        ));
6437        assert!(stack.contains(
6438            "state: stateView<OreBoard, { address: string }>('OreBoard/state', ['address'])"
6439        ));
6440        assert!(stack.contains(
6441            "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6442        ));
6443        assert_eq!(
6444            stack.matches(
6445                "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6446            )
6447            .count(),
6448            1
6449        );
6450    }
6451
6452    #[test]
6453    fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
6454        let stack_spec = SerializableStackSpec {
6455            ast_version: CURRENT_AST_VERSION.to_string(),
6456            stack_name: "DemoStream".to_string(),
6457            program_ids: vec!["Prog111".to_string()],
6458            idls: vec![IdlSnapshot {
6459                name: "demo".to_string(),
6460                program_id: Some("Prog111".to_string()),
6461                version: "0.1.0".to_string(),
6462                accounts: vec![],
6463                instructions: vec![IdlInstructionSnapshot {
6464                    name: "deposit".to_string(),
6465                    discriminator: vec![9],
6466                    discriminant: None,
6467                    docs: vec![],
6468                    accounts: vec![],
6469                    args: vec![
6470                        IdlFieldSnapshot {
6471                            name: "amount".to_string(),
6472                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
6473                            amount_hint: None,
6474                        },
6475                        IdlFieldSnapshot {
6476                            name: "mint".to_string(),
6477                            type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
6478                            amount_hint: None,
6479                        },
6480                    ],
6481                }],
6482                types: vec![],
6483                events: vec![],
6484                errors: vec![],
6485                discriminant_size: 1,
6486            }],
6487            program_specs: vec![demo_program_spec()],
6488            entities: vec![],
6489            pdas: BTreeMap::new(),
6490            instructions: vec![InstructionDef {
6491                name: "deposit".to_string(),
6492                discriminator: vec![9],
6493                discriminator_size: 1,
6494                accounts: vec![],
6495                args: vec![
6496                    InstructionArgDef {
6497                        name: "amount".to_string(),
6498                        arg_type: "u64".to_string(),
6499                        docs: vec![],
6500                        amount_hint: Some(InstructionAmountHint {
6501                            decimals_source: AmountDecimalsSource::ArgMint {
6502                                arg_name: "mint".to_string(),
6503                            },
6504                        }),
6505                    },
6506                    InstructionArgDef {
6507                        name: "mint".to_string(),
6508                        arg_type: "solana_pubkey::Pubkey".to_string(),
6509                        docs: vec![],
6510                        amount_hint: None,
6511                    },
6512                ],
6513                errors: vec![],
6514                program_id: Some("Prog111".to_string()),
6515                docs: vec![],
6516            }],
6517            content_hash: None,
6518        };
6519
6520        let output = compile_program_modules(stack_spec, None)
6521            .expect("program-module compilation should succeed");
6522        let file = output.full_file();
6523
6524        assert!(
6525            file.contains("type AmountInput"),
6526            "amount import missing:\n{}",
6527            file
6528        );
6529        assert!(
6530            file.contains("PROGRAM_OPERATION_EXTENSIONS"),
6531            "runtime extension import missing"
6532        );
6533        assert!(
6534            output.imports.contains("type BuildOptions"),
6535            "build options import missing"
6536        );
6537        assert!(
6538            output.imports.contains("type ProgramOperationContext"),
6539            "operation context import missing"
6540        );
6541        assert!(
6542            file.contains("resolveAmountToRaw"),
6543            "amount resolver import missing"
6544        );
6545        assert!(file.contains("export interface DepositSemanticParams"));
6546        assert!(file.contains("build?: BuildOptions;"));
6547        assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
6548        assert!(file.contains("createOperations(context: ProgramOperationContext)"));
6549        assert!(file
6550            .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
6551        assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
6552        assert!(file.contains("resolveAmountToRaw(context.chain"));
6553        assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
6554        assert!(file.contains("createPreparedInstruction({"));
6555    }
6556
6557    #[test]
6558    fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
6559        let path = concat!(
6560            env!("CARGO_MANIFEST_DIR"),
6561            "/../stacks/ore/.arete/OreStream.stack.json"
6562        );
6563        let json = match std::fs::read_to_string(path) {
6564            Ok(c) => c,
6565            Err(_) => return,
6566        };
6567        let spec: SerializableStackSpec =
6568            serde_json::from_str(&json).expect("ore stack json should deserialize");
6569
6570        if spec.program_specs.is_empty() {
6571            let error = compile_stack_spec(spec, None).unwrap_err();
6572            assert!(error.contains("Regenerate the .stack.json"));
6573            return;
6574        }
6575
6576        let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
6577        let file = output.full_file();
6578
6579        assert!(
6580            file.contains("endpoints:"),
6581            "stack endpoints missing:\n{}",
6582            file
6583        );
6584        assert!(
6585            file.contains("programs: {"),
6586            "program block missing:\n{}",
6587            file
6588        );
6589        assert!(
6590            file.contains("addresses: {"),
6591            "root addresses missing:\n{}",
6592            file
6593        );
6594        assert!(
6595            file.contains("instructions: {"),
6596            "program instructions missing:\n{}",
6597            file
6598        );
6599        assert!(
6600            file.contains("buildInstruction("),
6601            "instruction builders missing:\n{}",
6602            file
6603        );
6604    }
6605
6606    #[test]
6607    fn account_codegen_normalizes_raw_keys_and_nested_types() {
6608        let idl_snapshot = IdlSnapshot {
6609            name: "presale".to_string(),
6610            program_id: None,
6611            version: "0.1.0".to_string(),
6612            accounts: vec![IdlAccountSnapshot {
6613                name: "Presale".to_string(),
6614                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6615                docs: vec![],
6616                serialization: None,
6617                fields: vec![
6618                    IdlFieldSnapshot {
6619                        name: "owner".to_string(),
6620                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6621                        amount_hint: None,
6622                    },
6623                    IdlFieldSnapshot {
6624                        name: "total_deposit".to_string(),
6625                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
6626                        amount_hint: None,
6627                    },
6628                    IdlFieldSnapshot {
6629                        name: "optional_authority".to_string(),
6630                        type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
6631                            option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
6632                        }),
6633                        amount_hint: None,
6634                    },
6635                    IdlFieldSnapshot {
6636                        name: "createKey".to_string(),
6637                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6638                        amount_hint: None,
6639                    },
6640                    IdlFieldSnapshot {
6641                        name: "member".to_string(),
6642                        type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
6643                            defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
6644                        }),
6645                        amount_hint: None,
6646                    },
6647                ],
6648                type_def: None,
6649            }],
6650            instructions: vec![],
6651            types: vec![IdlTypeDefSnapshot {
6652                name: "MemberConfig".to_string(),
6653                docs: vec![],
6654                serialization: None,
6655                type_def: IdlTypeDefKindSnapshot::Struct {
6656                    kind: "struct".to_string(),
6657                    fields: vec![
6658                        IdlFieldSnapshot {
6659                            name: "last_updated_at".to_string(),
6660                            type_: IdlTypeSnapshot::Simple("i128".to_string()),
6661                            amount_hint: None,
6662                        },
6663                        IdlFieldSnapshot {
6664                            name: "authority_key".to_string(),
6665                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6666                            amount_hint: None,
6667                        },
6668                    ],
6669                },
6670            }],
6671            events: vec![],
6672            errors: vec![],
6673            discriminant_size: 8,
6674        };
6675
6676        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6677        let account_bigint = bigint_zod();
6678
6679        assert!(artifacts.code.contains("export interface Presale {"));
6680        assert!(
6681            artifacts.code.contains("owner: string;"),
6682            "missing owner field:\n{}",
6683            artifacts.code
6684        );
6685        assert!(
6686            artifacts.code.contains("totalDeposit: bigint;"),
6687            "missing totalDeposit field:\n{}",
6688            artifacts.code
6689        );
6690        assert!(
6691            artifacts.code.contains("optionalAuthority: string | null;"),
6692            "missing optionalAuthority field:\n{}",
6693            artifacts.code
6694        );
6695        assert!(
6696            artifacts.code.contains("createKey: string;"),
6697            "missing createKey field:\n{}",
6698            artifacts.code
6699        );
6700        assert!(
6701            artifacts.code.contains("member: MemberConfig;"),
6702            "missing nested type field:\n{}",
6703            artifacts.code
6704        );
6705        assert!(artifacts.code.contains("export interface MemberConfig {"));
6706        assert!(
6707            artifacts.code.contains("lastUpdatedAt: bigint;"),
6708            "missing nested bigint field:\n{}",
6709            artifacts.code
6710        );
6711        assert!(
6712            artifacts.code.contains("authorityKey: string;"),
6713            "missing nested camelCase field:\n{}",
6714            artifacts.code
6715        );
6716
6717        assert!(artifacts
6718            .code
6719            .contains("export const PresaleSchema = z.object({"));
6720        assert!(
6721            artifacts.code.contains("owner: z.string(),"),
6722            "missing owner schema field:\n{}",
6723            artifacts.code
6724        );
6725        assert!(
6726            artifacts
6727                .code
6728                .contains(&format!("total_deposit: {},", account_bigint)),
6729            "missing total_deposit schema field:\n{}",
6730            artifacts.code
6731        );
6732        assert!(
6733            artifacts
6734                .code
6735                .contains("optional_authority: z.string().nullable(),"),
6736            "missing optional_authority schema field:\n{}",
6737            artifacts.code
6738        );
6739        assert!(
6740            artifacts.code.contains("create_key: z.string(),"),
6741            "missing create_key schema field:\n{}",
6742            artifacts.code
6743        );
6744        assert!(
6745            artifacts
6746                .code
6747                .contains("member: z.lazy(() => MemberConfigSchema),"),
6748            "missing nested schema field:\n{}",
6749            artifacts.code
6750        );
6751        assert!(
6752            artifacts.code.contains("owner: value.owner,"),
6753            "missing owner transform:\n{}",
6754            artifacts.code
6755        );
6756        assert!(
6757            artifacts
6758                .code
6759                .contains("totalDeposit: value.total_deposit,"),
6760            "missing totalDeposit transform:\n{}",
6761            artifacts.code
6762        );
6763        assert!(
6764            artifacts
6765                .code
6766                .contains("optionalAuthority: value.optional_authority,"),
6767            "missing optionalAuthority transform:\n{}",
6768            artifacts.code
6769        );
6770        assert!(
6771            artifacts.code.contains("createKey: value.create_key,"),
6772            "missing createKey transform:\n{}",
6773            artifacts.code
6774        );
6775        assert!(
6776            artifacts.code.contains("member: value.member,"),
6777            "missing nested transform:\n{}",
6778            artifacts.code
6779        );
6780
6781        assert!(artifacts
6782            .code
6783            .contains("export const MemberConfigSchema = z.object({"));
6784        assert!(
6785            artifacts
6786                .code
6787                .contains(&format!("last_updated_at: {},", bigint_zod())),
6788            "missing nested bigint schema field:\n{}",
6789            artifacts.code
6790        );
6791        assert!(
6792            artifacts.code.contains("authority_key: z.string(),"),
6793            "missing nested schema field:\n{}",
6794            artifacts.code
6795        );
6796        assert!(
6797            artifacts
6798                .code
6799                .contains("lastUpdatedAt: value.last_updated_at,"),
6800            "missing nested bigint transform:\n{}",
6801            artifacts.code
6802        );
6803        assert!(
6804            artifacts
6805                .code
6806                .contains("authorityKey: value.authority_key,"),
6807            "missing nested camelCase transform:\n{}",
6808            artifacts.code
6809        );
6810    }
6811
6812    #[test]
6813    fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
6814        let idl_snapshot = IdlSnapshot {
6815            name: "presale".to_string(),
6816            program_id: None,
6817            version: "0.1.0".to_string(),
6818            accounts: vec![IdlAccountSnapshot {
6819                name: "Presale".to_string(),
6820                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6821                docs: vec![],
6822                serialization: None,
6823                fields: vec![],
6824                type_def: None,
6825            }],
6826            instructions: vec![],
6827            types: vec![IdlTypeDefSnapshot {
6828                name: "Presale".to_string(),
6829                docs: vec![],
6830                serialization: None,
6831                type_def: IdlTypeDefKindSnapshot::Struct {
6832                    kind: "struct".to_string(),
6833                    fields: vec![
6834                        IdlFieldSnapshot {
6835                            name: "owner".to_string(),
6836                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6837                            amount_hint: None,
6838                        },
6839                        IdlFieldSnapshot {
6840                            name: "total_deposit".to_string(),
6841                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
6842                            amount_hint: None,
6843                        },
6844                    ],
6845                },
6846            }],
6847            events: vec![],
6848            errors: vec![],
6849            discriminant_size: 8,
6850        };
6851
6852        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6853
6854        assert!(artifacts.code.contains("export interface Presale {"));
6855        assert!(
6856            artifacts.code.contains("owner: string;"),
6857            "missing owner field:\n{}",
6858            artifacts.code
6859        );
6860        assert!(
6861            artifacts.code.contains("totalDeposit: bigint;"),
6862            "missing totalDeposit field:\n{}",
6863            artifacts.code
6864        );
6865        assert!(
6866            artifacts
6867                .code
6868                .contains("export const PresaleSchema = z.object({"),
6869            "missing schema:\n{}",
6870            artifacts.code
6871        );
6872        assert!(
6873            artifacts.code.contains("owner: z.string(),"),
6874            "missing owner schema field:\n{}",
6875            artifacts.code
6876        );
6877        assert!(
6878            artifacts
6879                .code
6880                .contains(&format!("total_deposit: {},", bigint_zod())),
6881            "missing total_deposit schema field:\n{}",
6882            artifacts.code
6883        );
6884        assert!(
6885            artifacts.code.contains("owner: value.owner,"),
6886            "missing owner transform:\n{}",
6887            artifacts.code
6888        );
6889        assert!(
6890            artifacts
6891                .code
6892                .contains("totalDeposit: value.total_deposit,"),
6893            "missing totalDeposit transform:\n{}",
6894            artifacts.code
6895        );
6896    }
6897
6898    #[test]
6899    fn compile_program_modules_rejects_specs_without_idls() {
6900        let stack_spec = SerializableStackSpec {
6901            ast_version: CURRENT_AST_VERSION.to_string(),
6902            stack_name: "Empty".to_string(),
6903            program_ids: vec![],
6904            idls: vec![],
6905            program_specs: vec![],
6906            entities: vec![],
6907            pdas: BTreeMap::new(),
6908            instructions: vec![],
6909            content_hash: None,
6910        };
6911
6912        let error =
6913            compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
6914        assert!(error.contains("no IDLs"), "unexpected error: {}", error);
6915    }
6916}