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 program = match (&pda_def.program_id, &pda_def.program) {
4776                (Some(pid), _) => format!("'{}'", pid),
4777                (None, Some(PdaProgramDef::AccountRef { account_name })) => {
4778                    format!("{{ type: 'accountRef', accountName: '{}' }}", account_name)
4779                }
4780                (None, Some(PdaProgramDef::ArgRef { arg_name })) => {
4781                    format!("{{ type: 'argRef', argName: '{}' }}", arg_name)
4782                }
4783                (None, None) => format!("'{}'", default_program_id),
4784            };
4785            let rendered_seeds = if seeds_str.is_empty() {
4786                String::new()
4787            } else {
4788                format!(", {}", seeds_str)
4789            };
4790            format!(
4791                "{}{}: pda({}{}),",
4792                indent, pda_name, program, rendered_seeds
4793            )
4794        })
4795        .collect()
4796}
4797
4798fn generate_stack_addresses_block(
4799    idls: &[IdlSnapshot],
4800    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
4801    program_ids: &[String],
4802) -> String {
4803    if idls.is_empty() {
4804        return String::new();
4805    }
4806
4807    if idls.len() == 1 {
4808        let idl = &idls[0];
4809        let program_id = program_ids
4810            .first()
4811            .cloned()
4812            .or_else(|| idl.program_id.clone())
4813            .unwrap_or_default();
4814        let Some(program_pdas) = pdas
4815            .get(&idl.name)
4816            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4817        else {
4818            return String::new();
4819        };
4820        let entries = generate_program_pda_entries(program_pdas, &program_id, "    ");
4821        if entries.is_empty() {
4822            return String::new();
4823        }
4824        return format!("\n  addresses: {{\n{}\n  }},", entries.join("\n"));
4825    }
4826
4827    let mut blocks = Vec::new();
4828    for (index, idl) in idls.iter().enumerate() {
4829        let program_id = program_ids
4830            .get(index)
4831            .cloned()
4832            .or_else(|| idl.program_id.clone())
4833            .unwrap_or_default();
4834        let Some(program_pdas) = pdas
4835            .get(&idl.name)
4836            .or_else(|| pdas.get(&to_camel_case(&idl.name)))
4837        else {
4838            continue;
4839        };
4840        let entries = generate_program_pda_entries(program_pdas, &program_id, "      ");
4841        if entries.is_empty() {
4842            continue;
4843        }
4844        blocks.push(format!(
4845            "    {}: {{\n{}\n    }},",
4846            to_camel_case(&idl.name),
4847            entries.join("\n")
4848        ));
4849    }
4850
4851    if blocks.is_empty() {
4852        String::new()
4853    } else {
4854        format!("\n  addresses: {{\n{}\n  }},", blocks.join("\n"))
4855    }
4856}
4857
4858fn is_valid_ts_identifier(name: &str) -> bool {
4859    name.chars()
4860        .next()
4861        .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
4862        .unwrap_or(false)
4863        && name
4864            .chars()
4865            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
4866}
4867
4868fn escape_ts_single_quotes(value: &str) -> String {
4869    value
4870        .replace('\\', "\\\\")
4871        .replace('\'', "\\'")
4872        .replace(['\n', '\r'], " ")
4873}
4874
4875fn render_ts_property_name_literal(name: &str) -> String {
4876    if is_valid_ts_identifier(name) {
4877        name.to_string()
4878    } else {
4879        format!("'{}'", escape_ts_single_quotes(name))
4880    }
4881}
4882
4883fn render_program_semantic_instruction_entry(
4884    entry: &crate::typescript_instructions::StackInstructionEntry,
4885    indent: &str,
4886) -> Option<String> {
4887    let semantic_params_type = entry.semantic_params_type.as_ref()?;
4888    entry.runtime_program_key.as_ref()?;
4889
4890    if entry.semantic_amount_args.is_empty() {
4891        return Some(format!(
4892            "{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}}}),",
4893            indent = indent,
4894            instruction_name = entry.instruction_name,
4895            semantic_params_type = semantic_params_type,
4896            handler_const = entry.handler_const,
4897        ));
4898    }
4899
4900    let raw_params_setup = if entry.semantic_extra_params.is_empty() {
4901        format!(
4902            "{indent}    const {{ build, ...rawParams }} = params;",
4903            indent = indent
4904        )
4905    } else {
4906        format!(
4907            "{indent}    const {{ build, {extras}, ...rawParams }} = params;",
4908            indent = indent,
4909            extras = entry.semantic_extra_params.join(", "),
4910        )
4911    };
4912    let resolution_lines: Vec<String> = entry
4913        .semantic_amount_args
4914        .iter()
4915        .map(|amount_arg| {
4916            format!(
4917                "{indent}    const {binding_name} = {raw_expression};",
4918                indent = indent,
4919                binding_name = amount_arg.binding_name,
4920                raw_expression = amount_arg.raw_expression,
4921            )
4922        })
4923        .collect();
4924    let raw_assignments: Vec<String> = entry
4925        .semantic_amount_args
4926        .iter()
4927        .map(|amount_arg| {
4928            format!(
4929                "{indent}      {}: {},",
4930                render_ts_property_name_literal(&amount_arg.arg_name),
4931                amount_arg.binding_name,
4932                indent = indent,
4933            )
4934        })
4935        .collect();
4936
4937    Some(format!(
4938        "{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}  }}),",
4939        indent = indent,
4940        instruction_name = entry.instruction_name,
4941        semantic_params_type = semantic_params_type,
4942        raw_params_setup = raw_params_setup,
4943        resolutions = resolution_lines.join("\n"),
4944        handler_const = entry.handler_const,
4945        assignments = raw_assignments.join("\n"),
4946    ))
4947}
4948
4949fn generate_program_semantic_instructions_block(
4950    instruction_entries: &[&crate::typescript_instructions::StackInstructionEntry],
4951    indent: &str,
4952) -> Option<String> {
4953    let entry_indent = format!("{}      ", indent);
4954    let entries: Vec<String> = instruction_entries
4955        .iter()
4956        .filter_map(|entry| render_program_semantic_instruction_entry(entry, &entry_indent))
4957        .collect();
4958    if entries.is_empty() {
4959        return None;
4960    }
4961
4962    let context_param = if instruction_entries
4963        .iter()
4964        .any(|entry| entry.uses_operation_context)
4965    {
4966        "context: ProgramOperationContext"
4967    } else {
4968        ""
4969    };
4970
4971    Some(format!(
4972        "{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}}},",
4973        indent = indent,
4974        context_param = context_param,
4975        entries = entries.join("\n"),
4976    ))
4977}
4978
4979fn generate_view_helpers_static() -> String {
4980    r#"// ============================================================================
4981// View Definition Types (framework-agnostic)
4982// ============================================================================
4983
4984export type ViewKeyFields<TKey> = unknown extends TKey
4985  ? readonly string[]
4986  : TKey extends object
4987    ? readonly Extract<keyof TKey, string>[]
4988    : readonly string[];
4989
4990/** View definition with embedded entity and state-key types */
4991export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown> {
4992  readonly mode: TMode;
4993  readonly view: string;
4994  readonly keyFields?: ViewKeyFields<TKey>;
4995  /** Phantom field for type inference - not present at runtime */
4996  readonly _entity?: T;
4997  readonly _key?: TKey;
4998}
4999
5000/** Helper to create typed state view definitions (keyed lookups) */
5001function stateView<T, TKey = unknown>(
5002  view: string,
5003  keyFields: ViewKeyFields<TKey>
5004): ViewDef<T, 'state', TKey> {
5005  return { mode: 'state', view, keyFields } as const;
5006}
5007
5008/** Helper to create typed list view definitions (collections) */
5009function listView<T>(view: string): ViewDef<T, 'list'> {
5010  return { mode: 'list', view } as const;
5011}"#
5012    .to_string()
5013}
5014
5015/// Convert PascalCase to SCREAMING_SNAKE_CASE (e.g., "OreStream" -> "ORE_STREAM")
5016pub(crate) fn to_screaming_snake_case(s: &str) -> String {
5017    let mut result = String::new();
5018    for (i, ch) in s.chars().enumerate() {
5019        if ch.is_uppercase() && i > 0 {
5020            result.push('_');
5021        }
5022        result.push(ch.to_uppercase().next().unwrap());
5023    }
5024    result
5025}
5026
5027#[cfg(test)]
5028mod tests {
5029    use super::*;
5030
5031    fn demo_program_spec() -> arete_hash::ProgramSpecV1 {
5032        arete_hash::build_program_spec_v1_from_bytes(
5033            br#"{
5034              "address":"Prog111",
5035              "version":"0.1.0",
5036              "name":"demo",
5037              "instructions":[],
5038              "accounts":[],
5039              "types":[],
5040              "events":[],
5041              "errors":[]
5042            }"#,
5043            None,
5044        )
5045        .expect("test ProgramSpecV1")
5046    }
5047
5048    fn named_program_spec(name: &str, program_id: &str) -> arete_hash::ProgramSpecV1 {
5049        arete_hash::build_program_spec_v1_from_bytes(
5050            format!(
5051                r#"{{
5052                  "address":"{program_id}",
5053                  "version":"0.1.0",
5054                  "name":"{name}",
5055                  "instructions":[],
5056                  "accounts":[],
5057                  "types":[],
5058                  "events":[],
5059                  "errors":[]
5060                }}"#
5061            )
5062            .as_bytes(),
5063            None,
5064        )
5065        .expect("named test ProgramSpecV1")
5066    }
5067
5068    fn two_program_test_spec() -> SerializableStackSpec {
5069        let specs = vec![
5070            named_program_spec("second_program", "Program222"),
5071            named_program_spec("first_program", "Program111"),
5072        ];
5073        SerializableStackSpec {
5074            ast_version: CURRENT_AST_VERSION.to_string(),
5075            stack_name: "OrderedStream".to_string(),
5076            program_ids: specs.iter().map(|spec| spec.program_id.clone()).collect(),
5077            idls: specs
5078                .iter()
5079                .map(|spec| spec.idl_snapshot.snapshot.clone())
5080                .collect(),
5081            program_specs: specs,
5082            entities: vec![],
5083            pdas: BTreeMap::new(),
5084            instructions: vec![],
5085            content_hash: None,
5086        }
5087    }
5088
5089    fn program_only_test_spec(
5090        pdas: BTreeMap<String, BTreeMap<String, PdaDefinition>>,
5091        instructions: Vec<InstructionDef>,
5092    ) -> SerializableStackSpec {
5093        let program_spec = demo_program_spec();
5094        SerializableStackSpec {
5095            ast_version: CURRENT_AST_VERSION.to_string(),
5096            stack_name: "DemoStream".to_string(),
5097            program_ids: vec!["Prog111".to_string()],
5098            idls: vec![program_spec.idl_snapshot.snapshot.clone()],
5099            program_specs: vec![program_spec],
5100            entities: vec![],
5101            pdas,
5102            instructions,
5103            content_hash: None,
5104        }
5105    }
5106
5107    fn test_instruction(amount_hint: Option<InstructionAmountHint>) -> InstructionDef {
5108        InstructionDef {
5109            name: "deposit".to_string(),
5110            discriminator: vec![9],
5111            discriminator_size: 1,
5112            accounts: vec![],
5113            args: amount_hint
5114                .map(|amount_hint| InstructionArgDef {
5115                    name: "amount".to_string(),
5116                    arg_type: "u64".to_string(),
5117                    docs: vec![],
5118                    amount_hint: Some(amount_hint),
5119                })
5120                .into_iter()
5121                .collect(),
5122            errors: vec![],
5123            program_id: Some("Prog111".to_string()),
5124            docs: vec![],
5125        }
5126    }
5127
5128    fn state_key_test_spec(primary_keys: Vec<&str>) -> SerializableStreamSpec {
5129        let round_id = FieldTypeInfo::new("round_id".to_string(), "u64".to_string());
5130        SerializableStreamSpec {
5131            ast_version: CURRENT_AST_VERSION.to_string(),
5132            state_name: "OreRound".to_string(),
5133            program_id: None,
5134            idl: None,
5135            identity: IdentitySpec {
5136                primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
5137                lookup_indexes: vec![],
5138            },
5139            handlers: vec![],
5140            sections: vec![EntitySection {
5141                name: "id".to_string(),
5142                fields: vec![round_id.clone()],
5143                is_nested_struct: false,
5144                parent_field: None,
5145            }],
5146            field_mappings: BTreeMap::from([("id.round_id".to_string(), round_id)]),
5147            resolver_hooks: vec![],
5148            instruction_hooks: vec![],
5149            resolver_specs: vec![],
5150            computed_fields: vec![],
5151            computed_field_specs: vec![],
5152            content_hash: None,
5153            views: vec![],
5154        }
5155    }
5156
5157    fn endpoint_test_spec() -> SerializableStackSpec {
5158        SerializableStackSpec {
5159            ast_version: CURRENT_AST_VERSION.to_string(),
5160            stack_name: "EndpointStream".to_string(),
5161            program_ids: vec![],
5162            idls: vec![],
5163            program_specs: vec![],
5164            entities: vec![state_key_test_spec(vec!["id.round_id"])],
5165            pdas: BTreeMap::new(),
5166            instructions: vec![],
5167            content_hash: None,
5168        }
5169    }
5170
5171    #[test]
5172    fn test_case_conversions() {
5173        assert_eq!(to_pascal_case("settlement_game"), "SettlementGame");
5174        assert_eq!(to_kebab_case("SettlementGame"), "settlement-game");
5175    }
5176
5177    #[test]
5178    fn local_stack_codegen_is_endpointless_by_default() {
5179        let output = compile_stack_spec(endpoint_test_spec(), None)
5180            .expect("local stack generation should succeed");
5181
5182        assert!(output.stack_definition.contains(
5183            "  endpoints: {\n    ws: '', // TODO: Set after first deployment or pass useArete(..., { url })\n    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })\n  },"
5184        ));
5185    }
5186
5187    #[test]
5188    fn stack_codegen_emits_independent_endpoints_exactly() {
5189        let websocket_url = "wss://stream.example.test/ws/v2?tenant=endpoint";
5190        let http_url = "https://reads.unrelated.test/api/arete/v3";
5191        let output = compile_stack_spec(
5192            endpoint_test_spec(),
5193            Some(TypeScriptStackConfig {
5194                websocket_url: Some(websocket_url.to_string()),
5195                http_url: Some(http_url.to_string()),
5196                ..TypeScriptStackConfig::default()
5197            }),
5198        )
5199        .expect("configured stack generation should succeed");
5200
5201        assert!(output.stack_definition.contains(&format!(
5202            "  endpoints: {{\n    ws: '{}',\n    http: '{}',\n  }},",
5203            websocket_url, http_url
5204        )));
5205        assert!(!output
5206            .stack_definition
5207            .contains("https://stream.example.test/ws/v2"));
5208    }
5209
5210    #[test]
5211    fn explicit_local_websocket_does_not_derive_http() {
5212        let output = compile_stack_spec(
5213            endpoint_test_spec(),
5214            Some(TypeScriptStackConfig {
5215                websocket_url: Some("ws://127.0.0.1:8878/socket".to_string()),
5216                ..TypeScriptStackConfig::default()
5217            }),
5218        )
5219        .expect("configured local stack generation should succeed");
5220
5221        assert!(output
5222            .stack_definition
5223            .contains("    ws: 'ws://127.0.0.1:8878/socket',"));
5224        assert!(output.stack_definition.contains(
5225            "    http: '', // TODO: Set after first deployment or pass useArete(..., { httpUrl })"
5226        ));
5227        assert!(!output
5228            .stack_definition
5229            .contains("http://127.0.0.1:8878/socket"));
5230    }
5231
5232    #[test]
5233    fn test_normalize_for_comparison() {
5234        assert_eq!(normalize_for_comparison("claim_sol"), "claimsol");
5235        assert_eq!(normalize_for_comparison("claimSol"), "claimsol");
5236        assert_eq!(normalize_for_comparison("ClaimSol"), "claimsol");
5237        assert_eq!(
5238            normalize_for_comparison("admin_set_creator"),
5239            "adminsetcreator"
5240        );
5241        assert_eq!(
5242            normalize_for_comparison("AdminSetCreator"),
5243            "adminsetcreator"
5244        );
5245    }
5246
5247    #[test]
5248    fn test_value_to_typescript_type() {
5249        assert_eq!(value_to_typescript_type(&serde_json::json!(42)), "number");
5250        assert_eq!(
5251            value_to_typescript_type(&serde_json::json!("hello")),
5252            "string"
5253        );
5254        assert_eq!(
5255            value_to_typescript_type(&serde_json::json!(true)),
5256            "boolean"
5257        );
5258        assert_eq!(value_to_typescript_type(&serde_json::json!([])), "any[]");
5259    }
5260
5261    #[test]
5262    fn test_typescript_scalar_array_element() {
5263        assert_eq!(
5264            typescript_scalar_array_element("Vec < f64 >"),
5265            Some("number")
5266        );
5267        assert_eq!(typescript_scalar_array_element("Vec<f32>"), Some("number"));
5268        assert_eq!(typescript_scalar_array_element("f64"), Some("number"));
5269        assert_eq!(
5270            typescript_scalar_array_element("Vec < bool >"),
5271            Some("boolean")
5272        );
5273        assert_eq!(
5274            typescript_scalar_array_element("Vec < String >"),
5275            Some("string")
5276        );
5277        assert_eq!(typescript_scalar_array_element("Vec < u64 >"), None);
5278        assert_eq!(typescript_scalar_array_element("Vec < Pubkey >"), None);
5279    }
5280
5281    #[test]
5282    fn state_view_codegen_emits_exact_key_type_and_deduped_runtime_metadata() {
5283        let output = compile_serializable_spec(
5284            state_key_test_spec(vec!["id.round_id", "id.round_id"]),
5285            "OreRound".to_string(),
5286            None,
5287        )
5288        .expect("duplicate identical keys should compile");
5289
5290        assert!(output.stack_definition.contains(
5291            "export interface ViewDef<T, TMode extends 'state' | 'list', TKey = unknown>"
5292        ));
5293        assert!(output.stack_definition.contains(
5294            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
5295        ));
5296        assert_eq!(output.stack_definition.matches("['roundId']").count(), 1);
5297    }
5298
5299    #[test]
5300    fn state_view_codegen_rejects_distinct_composite_keys() {
5301        let mut spec = state_key_test_spec(vec!["id.round_id", "id.authority"]);
5302        spec.field_mappings.insert(
5303            "id.authority".to_string(),
5304            FieldTypeInfo::new("authority".to_string(), "String".to_string()),
5305        );
5306
5307        let error = compile_serializable_spec(spec, "OreRound".to_string(), None)
5308            .expect_err("distinct composite keys must fail generation");
5309
5310        assert!(error.contains("does not support composite state keys"));
5311        assert!(error.contains("id.round_id, id.authority"));
5312    }
5313
5314    #[test]
5315    fn pda_imports_follow_emitted_seed_variants() {
5316        let empty_seed_pdas = BTreeMap::from([(
5317            "demo".to_string(),
5318            BTreeMap::from([(
5319                "singleton".to_string(),
5320                PdaDefinition {
5321                    name: "singleton".to_string(),
5322                    seeds: vec![],
5323                    program_id: None,
5324                    program: None,
5325                },
5326            )]),
5327        )]);
5328        let output = compile_program_modules(program_only_test_spec(empty_seed_pdas, vec![]), None)
5329            .expect("empty-seed PDA generation should succeed");
5330        assert_eq!(
5331            output.imports,
5332            "import { z } from 'zod';\nimport { pda } from '@usearete/sdk';"
5333        );
5334        assert!(output
5335            .stack_definition
5336            .contains("singleton: pda('Prog111'),"));
5337        assert!(!output.stack_definition.contains("pda('Prog111', )"));
5338
5339        let literal_account_pdas = BTreeMap::from([(
5340            "demo".to_string(),
5341            BTreeMap::from([(
5342                "vault".to_string(),
5343                PdaDefinition {
5344                    name: "vault".to_string(),
5345                    seeds: vec![
5346                        PdaSeedDef::Literal {
5347                            value: "vault".to_string(),
5348                        },
5349                        PdaSeedDef::AccountRef {
5350                            account_name: "authority".to_string(),
5351                        },
5352                    ],
5353                    program_id: None,
5354                    program: None,
5355                },
5356            )]),
5357        )]);
5358        let output =
5359            compile_program_modules(program_only_test_spec(literal_account_pdas, vec![]), None)
5360                .expect("literal/account PDA generation should succeed");
5361        assert_eq!(
5362            output.imports,
5363            "import { z } from 'zod';\nimport { pda, literal, account } from '@usearete/sdk';"
5364        );
5365
5366        let arg_bytes_pdas = BTreeMap::from([(
5367            "demo".to_string(),
5368            BTreeMap::from([(
5369                "position".to_string(),
5370                PdaDefinition {
5371                    name: "position".to_string(),
5372                    seeds: vec![
5373                        PdaSeedDef::ArgRef {
5374                            arg_name: "roundId".to_string(),
5375                            arg_type: Some("u64".to_string()),
5376                        },
5377                        PdaSeedDef::Bytes {
5378                            value: vec![0, 255],
5379                        },
5380                    ],
5381                    program_id: None,
5382                    program: None,
5383                },
5384            )]),
5385        )]);
5386        let output = compile_program_modules(program_only_test_spec(arg_bytes_pdas, vec![]), None)
5387            .expect("arg/bytes PDA generation should succeed");
5388        assert_eq!(
5389            output.imports,
5390            "import { z } from 'zod';\nimport { pda, arg, bytes } from '@usearete/sdk';"
5391        );
5392    }
5393
5394    #[test]
5395    fn program_modules_emit_configured_sdk_definition_hash() {
5396        let stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5397        let mut program = TypeScriptProgramConfig::from(
5398            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5399        );
5400        program.definition.sdk_definition_hash = Some("definition-v1".to_string());
5401        let output = compile_program_modules(
5402            stack_spec,
5403            Some(TypeScriptStackConfig {
5404                programs: Some(vec![program]),
5405                ..TypeScriptStackConfig::default()
5406            }),
5407        )
5408        .expect("program definition generation should succeed");
5409
5410        assert!(output
5411            .stack_definition
5412            .contains("sdkDefinitionHash: 'definition-v1',"));
5413    }
5414
5415    #[test]
5416    fn program_modules_separate_portable_definition_from_release_reference() {
5417        let mut stack_spec = program_only_test_spec(BTreeMap::new(), vec![]);
5418        let mut local = TypeScriptProgramConfig::from(
5419            &arete_hash::OssProgramIdentityV1::new(stack_spec.program_specs[0].clone()).unwrap(),
5420        );
5421        local.definition.sdk_definition_hash = Some("portable-definition".to_string());
5422        let hosted_release = "arete:h1:program-release:sha256:hosted";
5423        let output = compile_program_modules(
5424            stack_spec.clone(),
5425            Some(TypeScriptStackConfig {
5426                programs: Some(vec![TypeScriptProgramConfig {
5427                    release: TypeScriptProgramReleaseReference {
5428                        program_release_hash: hosted_release.to_string(),
5429                        program_spec_hash: local.definition.program_spec_hash.clone(),
5430                    },
5431                    ..local.clone()
5432                }]),
5433                ..TypeScriptStackConfig::default()
5434            }),
5435        )
5436        .expect("program definition generation should succeed");
5437        let definition = output.stack_definition;
5438
5439        assert!(definition.contains("sdkDefinitionHash: 'portable-definition',"));
5440        assert!(definition.contains(&format!(
5441            "programSpecHash: '{}',",
5442            local.definition.program_spec_hash
5443        )));
5444        let programs = definition
5445            .split("/** Release and explicit read transport")
5446            .next()
5447            .unwrap();
5448        assert!(!programs.contains("programReleaseHash"));
5449        assert!(!programs.contains("decoderEngineId"));
5450        assert!(definition.contains(&format!("programReleaseHash: \"{hosted_release}\"")));
5451        assert!(!definition.contains("decoderEngineId"));
5452        stack_spec.program_specs.clear();
5453        assert!(compile_program_modules(stack_spec, None)
5454            .unwrap_err()
5455            .contains("Regenerate the .stack.json"));
5456    }
5457
5458    #[test]
5459    fn program_account_codegen_is_semantic_and_release_lives_in_program_reads() {
5460        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5461            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5462            None,
5463        )
5464        .expect("fixture identity");
5465        let stack_spec =
5466            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5467        let output = compile_program_modules(stack_spec, None).expect("program SDK generation");
5468
5469        assert!(output.stack_definition.contains(
5470            "programAccountRead<Presale>({ account: 'Presale', schema: PresaleSchema })"
5471        ));
5472        assert!(!output.stack_definition.contains("path:"));
5473        assert!(output.stack_definition.contains(&format!(
5474            "programReleaseHash: \"{}\"",
5475            identity.release_hash
5476        )));
5477        assert!(output
5478            .stack_definition
5479            .contains("transport: { kind: 'local-http', endpointSource: 'connect-http-url' }"));
5480        assert!(!output.stack_definition.contains("path:"));
5481    }
5482
5483    #[test]
5484    fn legacy_account_reader_ast_without_exact_program_specs_fails_closed() {
5485        let identity = crate::program_sdk::build_oss_program_identity_v1_from_idl_bytes(
5486            include_bytes!("../../arete-macros/tests/fixtures/nested-computed.idl.json"),
5487            None,
5488        )
5489        .expect("fixture identity");
5490        let mut stack_spec =
5491            crate::program_sdk::build_program_only_stack_spec_from_identity(&identity, "Presale");
5492        assert!(!stack_spec.idls[0].accounts.is_empty());
5493        stack_spec.program_specs.clear();
5494
5495        let error = compile_program_modules(stack_spec, None).unwrap_err();
5496        assert!(error.contains("no exact public ProgramSpecV1 values"));
5497        assert!(error.contains("Regenerate the .stack.json"));
5498    }
5499
5500    #[test]
5501    fn hosted_program_configs_preserve_order_releases_endpoints_and_auth() {
5502        let stack_spec = two_program_test_spec();
5503        let programs = stack_spec
5504            .program_specs
5505            .iter()
5506            .enumerate()
5507            .map(|(index, spec)| {
5508                let identity = arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap();
5509                let mut config = TypeScriptProgramConfig::from(&identity);
5510                config.release.program_release_hash = format!("hosted-release-{index}");
5511                let binding_id = format!("prb_{index:032}");
5512                config.transport =
5513                    TypeScriptProgramReadTransport::HostedBinding(TypeScriptProgramReadBinding {
5514                        endpoint: format!("https://reads.example.test/exact/{index}/"),
5515                        program_read_binding_id: binding_id.clone(),
5516                        auth: serde_json::json!({
5517                            "targetKind": "program-read-binding",
5518                            "targetId": binding_id,
5519                            "sessionEndpoint": format!("https://auth.example.test/{index}"),
5520                            "index": index
5521                        }),
5522                    });
5523                config
5524            })
5525            .collect::<Vec<_>>();
5526
5527        let output = compile_stack_spec(
5528            stack_spec,
5529            Some(TypeScriptStackConfig {
5530                programs: Some(programs),
5531                ..Default::default()
5532            }),
5533        )
5534        .expect("ordered hosted descriptors should compile");
5535        let generated = output.stack_definition;
5536        let portable = generated
5537            .split("  programs: {")
5538            .nth(1)
5539            .expect("portable programs block")
5540            .split("  programReads: {")
5541            .next()
5542            .expect("portable programs block end");
5543        let reads = generated
5544            .split("  programReads: {")
5545            .nth(1)
5546            .expect("parallel program reads block");
5547
5548        assert!(portable.find("secondProgram:").unwrap() < portable.find("firstProgram:").unwrap());
5549        assert!(!portable.contains("programReleaseHash"));
5550        assert!(!portable.contains("endpoint"));
5551        assert!(reads.find("secondProgram:").unwrap() < reads.find("firstProgram:").unwrap());
5552        assert!(reads.contains("programReleaseHash: \"hosted-release-0\""));
5553        assert!(reads.contains("programReleaseHash: \"hosted-release-1\""));
5554        assert!(reads.contains("kind: 'hosted-binding'"));
5555        assert!(reads.contains("endpoint: \"https://reads.example.test/exact/0/\""));
5556        assert!(reads.contains("programReadBindingId: \"prb_00000000000000000000000000000001\""));
5557        assert!(reads.contains(
5558            "auth: {\"index\":0,\"sessionEndpoint\":\"https://auth.example.test/0\",\"targetId\":\"prb_00000000000000000000000000000000\",\"targetKind\":\"program-read-binding\"}"
5559        ));
5560    }
5561
5562    #[test]
5563    fn hosted_program_config_mismatches_fail_by_index_without_name_fallback() {
5564        let stack_spec = two_program_test_spec();
5565        let local = stack_spec
5566            .program_specs
5567            .iter()
5568            .map(|spec| {
5569                TypeScriptProgramConfig::from(
5570                    &arete_hash::OssProgramIdentityV1::new(spec.clone()).unwrap(),
5571                )
5572            })
5573            .collect::<Vec<_>>();
5574
5575        let count_error = compile_program_modules(
5576            stack_spec.clone(),
5577            Some(TypeScriptStackConfig {
5578                programs: Some(vec![local[0].clone()]),
5579                ..Default::default()
5580            }),
5581        )
5582        .unwrap_err();
5583        assert!(count_error.contains("descriptor count mismatch"));
5584
5585        let mut swapped = local.clone();
5586        swapped.swap(0, 1);
5587        let order_error = compile_program_modules(
5588            stack_spec.clone(),
5589            Some(TypeScriptStackConfig {
5590                programs: Some(swapped),
5591                ..Default::default()
5592            }),
5593        )
5594        .unwrap_err();
5595        assert!(order_error.contains("program ID mismatch at index 0"));
5596
5597        let mut bad_hash = local;
5598        bad_hash[1].definition.program_spec_hash = "wrong-spec".to_string();
5599        let hash_error = compile_program_modules(
5600            stack_spec,
5601            Some(TypeScriptStackConfig {
5602                programs: Some(bad_hash),
5603                ..Default::default()
5604            }),
5605        )
5606        .unwrap_err();
5607        assert!(hash_error.contains("programSpecHash mismatch at index 1"));
5608    }
5609
5610    #[test]
5611    fn raw_operations_omit_semantic_only_types_and_context() {
5612        let output = compile_program_modules(
5613            program_only_test_spec(BTreeMap::new(), vec![test_instruction(None)]),
5614            None,
5615        )
5616        .expect("raw operation generation should succeed");
5617        let file = output.full_file();
5618
5619        assert!(!output.imports.contains("BuildOptions"));
5620        assert!(!output.imports.contains("ProgramOperationContext"));
5621        assert!(file.contains("createOperations()"));
5622        assert!(!file.contains("build?: BuildOptions;"));
5623    }
5624
5625    #[test]
5626    fn context_free_amount_operations_import_build_options_only() {
5627        let output = compile_program_modules(
5628            program_only_test_spec(
5629                BTreeMap::new(),
5630                vec![test_instruction(Some(InstructionAmountHint {
5631                    decimals_source: AmountDecimalsSource::Constant { decimals: 9 },
5632                }))],
5633            ),
5634            None,
5635        )
5636        .expect("constant amount operation generation should succeed");
5637        let file = output.full_file();
5638
5639        assert!(output.imports.contains("type BuildOptions"));
5640        assert!(output.imports.contains("toRawAmount"));
5641        assert!(!output.imports.contains("ProgramOperationContext"));
5642        assert!(file.contains("build?: BuildOptions;"));
5643        assert!(file.contains("createOperations()"));
5644        assert!(!file.contains("context.chain"));
5645    }
5646
5647    #[test]
5648    fn streamed_entity_codegen_normalizes_canonical_names_and_schemas() {
5649        let spec = SerializableStreamSpec {
5650            ast_version: CURRENT_AST_VERSION.to_string(),
5651            state_name: "TokenPosition".to_string(),
5652            program_id: None,
5653            idl: None,
5654            identity: IdentitySpec {
5655                primary_keys: vec!["id.address".to_string()],
5656                lookup_indexes: vec![],
5657            },
5658            handlers: vec![],
5659            sections: vec![
5660                EntitySection {
5661                    name: "root".to_string(),
5662                    fields: vec![FieldTypeInfo::new(
5663                        "total_deposit".to_string(),
5664                        "u64".to_string(),
5665                    )],
5666                    is_nested_struct: false,
5667                    parent_field: None,
5668                },
5669                EntitySection {
5670                    name: "metrics".to_string(),
5671                    fields: vec![FieldTypeInfo::new(
5672                        "last_updated_at".to_string(),
5673                        "i64".to_string(),
5674                    )],
5675                    is_nested_struct: false,
5676                    parent_field: None,
5677                },
5678            ],
5679            field_mappings: BTreeMap::new(),
5680            resolver_hooks: vec![],
5681            resolver_specs: vec![],
5682            instruction_hooks: vec![],
5683            computed_fields: vec![],
5684            computed_field_specs: vec![],
5685            content_hash: None,
5686            views: vec![],
5687        };
5688
5689        let output = compile_serializable_spec(spec, "TokenPosition".to_string(), None)
5690            .expect("should compile");
5691        let file = output.full_file();
5692
5693        assert!(
5694            file.contains("export interface TokenPosition {"),
5695            "missing main interface:\n{}",
5696            file
5697        );
5698        assert!(
5699            file.contains("totalDeposit: bigint;"),
5700            "missing root canonical field:\n{}",
5701            file
5702        );
5703        assert!(
5704            file.contains("metrics: TokenPositionMetrics;"),
5705            "missing nested canonical section field:\n{}",
5706            file
5707        );
5708        assert!(
5709            file.contains("export interface TokenPositionMetrics {"),
5710            "missing nested interface:\n{}",
5711            file
5712        );
5713        assert!(
5714            file.contains("lastUpdatedAt: bigint;"),
5715            "missing nested canonical field:\n{}",
5716            file
5717        );
5718
5719        let bigint_schema = bigint_zod();
5720        assert!(
5721            file.contains("export const TokenPositionSchema = z.object({"),
5722            "missing main schema:\n{}",
5723            file
5724        );
5725        assert!(
5726            file.contains(&format!("total_deposit: {},", bigint_schema)),
5727            "missing raw root schema field:\n{}",
5728            file
5729        );
5730        assert!(
5731            file.contains("metrics: TokenPositionMetricsSchema,"),
5732            "missing nested schema ref:\n{}",
5733            file
5734        );
5735        assert!(
5736            file.contains("totalDeposit: value.total_deposit,"),
5737            "missing root transform:\n{}",
5738            file
5739        );
5740        assert!(
5741            file.contains("metrics: value.metrics,"),
5742            "missing nested transform:\n{}",
5743            file
5744        );
5745
5746        assert!(
5747            file.contains("export const TokenPositionMetricsSchema = z.object({"),
5748            "missing nested schema:\n{}",
5749            file
5750        );
5751        assert!(
5752            file.contains(&format!("last_updated_at: {},", bigint_schema)),
5753            "missing raw nested schema field:\n{}",
5754            file
5755        );
5756        assert!(
5757            file.contains("lastUpdatedAt: value.last_updated_at,"),
5758            "missing nested transform:\n{}",
5759            file
5760        );
5761
5762        assert!(
5763            file.contains("export const TokenPositionPatchSchema = z.object({"),
5764            "missing patch schema:\n{}",
5765            file
5766        );
5767        assert!(
5768            file.contains(&format!("total_deposit: {}.optional(),", bigint_schema)),
5769            "missing sparse patch field:\n{}",
5770            file
5771        );
5772        assert!(
5773            file.contains("metrics: TokenPositionMetricsPatchSchema.optional(),"),
5774            "missing nested patch schema ref:\n{}",
5775            file
5776        );
5777        assert!(
5778            file.contains("...(value.total_deposit !== undefined ? { totalDeposit: value.total_deposit } : {}),"),
5779            "missing sparse patch transform:\n{}",
5780            file
5781        );
5782        assert!(
5783            file.contains("export const TokenPositionMetricsPatchSchema = z.object({"),
5784            "missing nested patch schema:\n{}",
5785            file
5786        );
5787        assert!(
5788            file.contains("patchSchemas: {\n    TokenPosition: TokenPositionPatchSchema,"),
5789            "missing stack patch schema map:\n{}",
5790            file
5791        );
5792    }
5793
5794    #[test]
5795    fn captured_accounts_keep_the_runtime_envelope_and_full_inner_schema() {
5796        let miner_snapshot = FieldTypeInfo {
5797            field_name: "miner_snapshot".to_string(),
5798            raw_name: Some("miner_snapshot".to_string()),
5799            canonical_name: Some("minerSnapshot".to_string()),
5800            rust_type_name: "Option<Miner>".to_string(),
5801            base_type: BaseType::Object,
5802            integer_kind: None,
5803            is_optional: true,
5804            is_array: false,
5805            inner_type: Some("Miner".to_string()),
5806            source_path: None,
5807            resolved_type: Some(ResolvedStructType {
5808                type_name: "Miner".to_string(),
5809                fields: vec![
5810                    ResolvedField {
5811                        field_name: "deployed".to_string(),
5812                        raw_name: Some("deployed".to_string()),
5813                        canonical_name: Some("deployed".to_string()),
5814                        field_type: "u64".to_string(),
5815                        base_type: BaseType::Integer,
5816                        integer_kind: Some(IntegerKind::U64),
5817                        is_optional: false,
5818                        is_array: true,
5819                    },
5820                    ResolvedField {
5821                        field_name: "round_id".to_string(),
5822                        raw_name: Some("round_id".to_string()),
5823                        canonical_name: Some("roundId".to_string()),
5824                        field_type: "u64".to_string(),
5825                        base_type: BaseType::Integer,
5826                        integer_kind: Some(IntegerKind::U64),
5827                        is_optional: false,
5828                        is_array: false,
5829                    },
5830                ],
5831                is_instruction: false,
5832                is_account: true,
5833                is_event: false,
5834                is_enum: false,
5835                enum_variants: vec![],
5836            }),
5837            emit: true,
5838        };
5839        let spec = SerializableStreamSpec {
5840            ast_version: CURRENT_AST_VERSION.to_string(),
5841            state_name: "OreMiner".to_string(),
5842            program_id: None,
5843            idl: None,
5844            identity: IdentitySpec {
5845                primary_keys: vec!["id.authority".to_string()],
5846                lookup_indexes: vec![],
5847            },
5848            handlers: vec![SerializableHandlerSpec {
5849                source: SourceSpec::Source {
5850                    program_id: None,
5851                    discriminator: None,
5852                    type_name: "Miner".to_string(),
5853                    serialization: None,
5854                    is_account: true,
5855                },
5856                key_resolution: KeyResolutionStrategy::Embedded {
5857                    primary_field: FieldPath::new(&["authority"]),
5858                },
5859                mappings: vec![SerializableFieldMapping {
5860                    target_path: "miner_snapshot".to_string(),
5861                    source: MappingSource::AsCapture {
5862                        field_transforms: BTreeMap::new(),
5863                    },
5864                    transform: None,
5865                    population: PopulationStrategy::LastWrite,
5866                    condition: None,
5867                    when: None,
5868                    stop: None,
5869                    emit: true,
5870                }],
5871                conditions: vec![],
5872                emit: true,
5873            }],
5874            sections: vec![
5875                EntitySection {
5876                    name: "id".to_string(),
5877                    fields: vec![FieldTypeInfo::new(
5878                        "authority".to_string(),
5879                        "String".to_string(),
5880                    )],
5881                    is_nested_struct: false,
5882                    parent_field: None,
5883                },
5884                EntitySection {
5885                    name: "root".to_string(),
5886                    fields: vec![miner_snapshot],
5887                    is_nested_struct: false,
5888                    parent_field: None,
5889                },
5890            ],
5891            field_mappings: BTreeMap::new(),
5892            resolver_hooks: vec![],
5893            resolver_specs: vec![],
5894            instruction_hooks: vec![],
5895            computed_fields: vec![],
5896            computed_field_specs: vec![],
5897            content_hash: None,
5898            views: vec![],
5899        };
5900
5901        let output = compile_serializable_spec(spec, "OreMiner".to_string(), None)
5902            .expect("captured account generation should succeed");
5903        let file = output.full_file();
5904
5905        assert!(file.contains("minerSnapshot: CaptureWrapper<Miner> | null;"));
5906        assert!(file.contains("export interface CaptureWrapper<T> {"));
5907        assert!(file.contains("accountAddress: string;"));
5908        assert!(file.contains("account_address: z.string(),"));
5909        assert!(file.contains("accountAddress: value.account_address,"));
5910        assert!(file
5911            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5912        assert!(file
5913            .contains("miner_snapshot: CaptureWrapperSchema(MinerSchema).nullable().optional(),"));
5914        assert!(!file.contains("CaptureWrapperSchema(MinerPatchSchema)"));
5915    }
5916
5917    #[test]
5918    fn streamed_builtin_token_metadata_codegen_is_canonical_and_sparse() {
5919        let spec = SerializableStreamSpec {
5920            ast_version: CURRENT_AST_VERSION.to_string(),
5921            state_name: "TokenHolder".to_string(),
5922            program_id: None,
5923            idl: None,
5924            identity: IdentitySpec {
5925                primary_keys: vec!["id.address".to_string()],
5926                lookup_indexes: vec![],
5927            },
5928            handlers: vec![],
5929            sections: vec![EntitySection {
5930                name: "root".to_string(),
5931                fields: vec![FieldTypeInfo {
5932                    field_name: "base_token_metadata".to_string(),
5933                    raw_name: Some("base_token_metadata".to_string()),
5934                    canonical_name: Some("baseTokenMetadata".to_string()),
5935                    rust_type_name: "Option<TokenMetadata>".to_string(),
5936                    base_type: BaseType::Object,
5937                    integer_kind: None,
5938                    is_optional: true,
5939                    is_array: false,
5940                    inner_type: Some("TokenMetadata".to_string()),
5941                    source_path: None,
5942                    resolved_type: None,
5943                    emit: true,
5944                }],
5945                is_nested_struct: false,
5946                parent_field: None,
5947            }],
5948            field_mappings: BTreeMap::new(),
5949            resolver_hooks: vec![],
5950            resolver_specs: vec![],
5951            instruction_hooks: vec![],
5952            computed_fields: vec![],
5953            computed_field_specs: vec![],
5954            content_hash: None,
5955            views: vec![],
5956        };
5957
5958        let output = compile_serializable_spec(spec, "TokenHolder".to_string(), None)
5959            .expect("should compile");
5960        let file = output.full_file();
5961
5962        assert!(
5963            file.contains("export interface TokenMetadata {"),
5964            "missing builtin interface:\n{}",
5965            file
5966        );
5967        assert!(
5968            file.contains("logoUri?: string | null;"),
5969            "missing canonical builtin field:\n{}",
5970            file
5971        );
5972        assert!(
5973            file.contains("export const TokenMetadataSchema = z.object({"),
5974            "missing builtin schema:\n{}",
5975            file
5976        );
5977        assert!(
5978            file.contains("logo_uri: z.string().nullable().optional(),"),
5979            "missing raw builtin input field:\n{}",
5980            file
5981        );
5982        assert!(
5983            file.contains("...(value.logo_uri !== undefined ? { logoUri: value.logo_uri } : {}),"),
5984            "missing canonical builtin transform:\n{}",
5985            file
5986        );
5987        assert!(
5988            file.contains("export const TokenMetadataPatchSchema = z.object({"),
5989            "missing builtin patch schema:\n{}",
5990            file
5991        );
5992        assert!(
5993            file.contains("base_token_metadata: TokenMetadataPatchSchema.nullable().optional(),"),
5994            "missing patch schema usage for builtin field:\n{}",
5995            file
5996        );
5997    }
5998
5999    #[test]
6000    fn streamed_section_codegen_localizes_prefixed_raw_field_names() {
6001        let spec = SerializableStreamSpec {
6002            ast_version: CURRENT_AST_VERSION.to_string(),
6003            state_name: "OreRound".to_string(),
6004            program_id: None,
6005            idl: None,
6006            identity: IdentitySpec {
6007                primary_keys: vec!["id.round_id".to_string()],
6008                lookup_indexes: vec![],
6009            },
6010            handlers: vec![],
6011            sections: vec![EntitySection {
6012                name: "results".to_string(),
6013                fields: vec![FieldTypeInfo {
6014                    field_name: "results.expires_at_slot_hash".to_string(),
6015                    raw_name: Some("results.expires_at_slot_hash".to_string()),
6016                    canonical_name: Some("resultsExpiresAtSlotHash".to_string()),
6017                    rust_type_name: "Option<String>".to_string(),
6018                    base_type: BaseType::String,
6019                    integer_kind: None,
6020                    is_optional: true,
6021                    is_array: false,
6022                    inner_type: Some("String".to_string()),
6023                    source_path: None,
6024                    resolved_type: None,
6025                    emit: true,
6026                }],
6027                is_nested_struct: false,
6028                parent_field: None,
6029            }],
6030            field_mappings: BTreeMap::new(),
6031            resolver_hooks: vec![],
6032            resolver_specs: vec![],
6033            instruction_hooks: vec![],
6034            computed_fields: vec![],
6035            computed_field_specs: vec![],
6036            content_hash: None,
6037            views: vec![],
6038        };
6039
6040        let output =
6041            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6042        let file = output.full_file();
6043
6044        assert!(
6045            file.contains("export interface OreRoundResults {"),
6046            "missing section interface:\n{}",
6047            file
6048        );
6049        assert!(
6050            file.contains("expiresAtSlotHash: string | null;"),
6051            "missing localized canonical field:\n{}",
6052            file
6053        );
6054        assert!(
6055            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6056            "missing localized canonical schema field:\n{}",
6057            file
6058        );
6059        assert!(
6060            file.contains("expiresAtSlotHash: value.expires_at_slot_hash,"),
6061            "missing localized transform:\n{}",
6062            file
6063        );
6064        assert!(
6065            file.contains("expires_at_slot_hash: z.string().nullable().optional(),"),
6066            "missing localized patch schema field:\n{}",
6067            file
6068        );
6069        assert!(
6070            file.contains("...(value.expires_at_slot_hash !== undefined ? { expiresAtSlotHash: value.expires_at_slot_hash } : {}),"),
6071            "missing localized sparse transform:\n{}",
6072            file
6073        );
6074    }
6075
6076    #[test]
6077    fn test_streamed_completed_schema_allows_unwritten_nullable_fields() {
6078        let mut optional_count = FieldTypeInfo::new("count".to_string(), "u64".to_string());
6079        optional_count.is_optional = true;
6080        let spec = SerializableStreamSpec {
6081            ast_version: CURRENT_AST_VERSION.to_string(),
6082            state_name: "OreRound".to_string(),
6083            program_id: None,
6084            idl: None,
6085            identity: IdentitySpec {
6086                primary_keys: vec!["id.round_id".to_string()],
6087                lookup_indexes: vec![],
6088            },
6089            handlers: vec![],
6090            sections: vec![EntitySection {
6091                name: "state".to_string(),
6092                fields: vec![optional_count],
6093                is_nested_struct: false,
6094                parent_field: None,
6095            }],
6096            field_mappings: BTreeMap::new(),
6097            resolver_hooks: vec![],
6098            resolver_specs: vec![],
6099            instruction_hooks: vec![],
6100            computed_fields: vec![],
6101            computed_field_specs: vec![],
6102            content_hash: None,
6103            views: vec![],
6104        };
6105
6106        let output =
6107            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6108        let file = output.full_file();
6109        assert!(
6110            file.contains("count: z.union([z.bigint(), z.string(), z.number().int()]).transform((value) => BigInt(value)).nullable().optional(),"),
6111            "completed schema should allow absent nullable fields:\n{}",
6112            file
6113        );
6114    }
6115
6116    #[test]
6117    fn test_derived_view_codegen() {
6118        let spec = SerializableStreamSpec {
6119            ast_version: CURRENT_AST_VERSION.to_string(),
6120            state_name: "OreRound".to_string(),
6121            program_id: None,
6122            idl: None,
6123            identity: IdentitySpec {
6124                primary_keys: vec!["id".to_string()],
6125                lookup_indexes: vec![],
6126            },
6127            handlers: vec![],
6128            sections: vec![],
6129            field_mappings: BTreeMap::new(),
6130            resolver_hooks: vec![],
6131            resolver_specs: vec![],
6132            instruction_hooks: vec![],
6133            computed_fields: vec![],
6134            computed_field_specs: vec![],
6135            content_hash: None,
6136            views: vec![
6137                ViewDef {
6138                    id: "OreRound/latest".to_string(),
6139                    source: ViewSource::Entity {
6140                        name: "OreRound".to_string(),
6141                    },
6142                    pipeline: vec![ViewTransform::Last],
6143                    output: ViewOutput::Single,
6144                },
6145                ViewDef {
6146                    id: "OreRound/top10".to_string(),
6147                    source: ViewSource::Entity {
6148                        name: "OreRound".to_string(),
6149                    },
6150                    pipeline: vec![ViewTransform::Take { count: 10 }],
6151                    output: ViewOutput::Collection,
6152                },
6153            ],
6154        };
6155
6156        let output =
6157            compile_serializable_spec(spec, "OreRound".to_string(), None).expect("should compile");
6158
6159        let stack_def = &output.stack_definition;
6160
6161        assert!(
6162            stack_def.contains("listView<OreRound>('OreRound/latest')"),
6163            "Expected 'latest' derived view using listView, got:\n{}",
6164            stack_def
6165        );
6166        assert!(
6167            stack_def.contains("listView<OreRound>('OreRound/top10')"),
6168            "Expected 'top10' derived view using listView, got:\n{}",
6169            stack_def
6170        );
6171        assert!(
6172            stack_def.contains("latest:"),
6173            "Expected 'latest' key, got:\n{}",
6174            stack_def
6175        );
6176        assert!(
6177            stack_def.contains("top10:"),
6178            "Expected 'top10' key, got:\n{}",
6179            stack_def
6180        );
6181        assert!(
6182            stack_def.contains("function listView<T>(view: string): ViewDef<T, 'list'>"),
6183            "Expected listView helper function, got:\n{}",
6184            stack_def
6185        );
6186    }
6187
6188    #[test]
6189    fn test_account_type_collision_uses_account_suffix() {
6190        let plan_field = FieldTypeInfo {
6191            field_name: "plan".to_string(),
6192            raw_name: Some("plan".to_string()),
6193            canonical_name: Some("plan".to_string()),
6194            rust_type_name: "Option<serde_json::Value>".to_string(),
6195            base_type: BaseType::Object,
6196            integer_kind: None,
6197            is_optional: false,
6198            is_array: false,
6199            inner_type: Some("Value".to_string()),
6200            source_path: None,
6201            resolved_type: Some(ResolvedStructType {
6202                type_name: "plan".to_string(),
6203                fields: vec![],
6204                is_instruction: false,
6205                is_account: true,
6206                is_event: false,
6207                is_enum: false,
6208                enum_variants: vec![],
6209            }),
6210            emit: true,
6211        };
6212
6213        let spec = SerializableStreamSpec {
6214            ast_version: CURRENT_AST_VERSION.to_string(),
6215            state_name: "Plan".to_string(),
6216            program_id: None,
6217            idl: None,
6218            identity: IdentitySpec {
6219                primary_keys: vec!["id.address".to_string()],
6220                lookup_indexes: vec![],
6221            },
6222            handlers: vec![],
6223            sections: vec![
6224                EntitySection {
6225                    name: "id".to_string(),
6226                    fields: vec![FieldTypeInfo::new(
6227                        "address".to_string(),
6228                        "String".to_string(),
6229                    )],
6230                    is_nested_struct: false,
6231                    parent_field: None,
6232                },
6233                EntitySection {
6234                    name: "plan".to_string(),
6235                    fields: vec![plan_field],
6236                    is_nested_struct: false,
6237                    parent_field: None,
6238                },
6239            ],
6240            field_mappings: BTreeMap::new(),
6241            resolver_hooks: vec![],
6242            instruction_hooks: vec![],
6243            resolver_specs: vec![],
6244            computed_fields: vec![],
6245            computed_field_specs: vec![],
6246            content_hash: None,
6247            views: vec![],
6248        };
6249
6250        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
6251            .expect("typescript sdk generation should succeed");
6252
6253        assert!(
6254            output.interfaces.contains("export interface PlanPlan {"),
6255            "expected PlanPlan section interface, got:\n{}",
6256            output.interfaces
6257        );
6258        assert!(
6259            output.interfaces.contains("plan: PlanAccount;"),
6260            "expected PlanAccount field reference, got:\n{}",
6261            output.interfaces
6262        );
6263        assert!(
6264            output.interfaces.contains("export interface PlanAccount {"),
6265            "expected PlanAccount interface, got:\n{}",
6266            output.interfaces
6267        );
6268    }
6269
6270    #[test]
6271    fn test_multi_entity_enum_dedup_uses_pascal_case_name_matching() {
6272        let shared_idl = serde_json::json!({
6273            "name": "subscriptions",
6274            "version": "0.1.0",
6275            "accounts": [],
6276            "instructions": [],
6277            "types": [
6278                {
6279                    "name": "planStatus",
6280                    "type": {
6281                        "kind": "enum",
6282                        "variants": [{ "name": "sunset" }, { "name": "active" }]
6283                    }
6284                }
6285            ],
6286            "events": [],
6287            "errors": [],
6288            "discriminant_size": 8
6289        });
6290
6291        let idl_snapshot: IdlSnapshot =
6292            serde_json::from_value(shared_idl).expect("idl snapshot should deserialize");
6293
6294        let make_entity = |name: &str| SerializableStreamSpec {
6295            ast_version: CURRENT_AST_VERSION.to_string(),
6296            state_name: name.to_string(),
6297            program_id: None,
6298            idl: None,
6299            identity: IdentitySpec {
6300                primary_keys: vec!["id.address".to_string()],
6301                lookup_indexes: vec![],
6302            },
6303            handlers: vec![],
6304            sections: vec![EntitySection {
6305                name: "id".to_string(),
6306                fields: vec![FieldTypeInfo::new(
6307                    "address".to_string(),
6308                    "String".to_string(),
6309                )],
6310                is_nested_struct: false,
6311                parent_field: None,
6312            }],
6313            field_mappings: BTreeMap::new(),
6314            resolver_hooks: vec![],
6315            instruction_hooks: vec![],
6316            resolver_specs: vec![],
6317            computed_fields: vec![],
6318            computed_field_specs: vec![],
6319            content_hash: None,
6320            views: vec![],
6321        };
6322
6323        let stack_spec = SerializableStackSpec {
6324            ast_version: CURRENT_AST_VERSION.to_string(),
6325            stack_name: "Subscriptions".to_string(),
6326            program_ids: vec![],
6327            idls: vec![idl_snapshot],
6328            program_specs: vec![],
6329            entities: vec![make_entity("Plan"), make_entity("Subscription")],
6330            pdas: BTreeMap::new(),
6331            instructions: vec![],
6332            content_hash: None,
6333        };
6334
6335        let output =
6336            compile_stack_spec(stack_spec, None).expect("stack compilation should succeed");
6337        let file = output.full_file();
6338        let count = output
6339            .interfaces
6340            .matches("export type PlanStatus =")
6341            .count();
6342
6343        assert_eq!(
6344            count, 1,
6345            "expected shared enum type to be emitted once, got:\n{}",
6346            output.interfaces
6347        );
6348        assert!(
6349            file.contains("_STACK_CORE = {"),
6350            "core export missing:\n{}",
6351            file
6352        );
6353        assert!(
6354            !file.contains("extendStack"),
6355            "no extension wiring expected:\n{}",
6356            file
6357        );
6358    }
6359
6360    #[test]
6361    fn golden_ore_stack_json_compiles_program_modules_without_entities() {
6362        let path = concat!(
6363            env!("CARGO_MANIFEST_DIR"),
6364            "/../stacks/ore/.arete/OreStream.stack.json"
6365        );
6366        let json = match std::fs::read_to_string(path) {
6367            Ok(c) => c,
6368            // Stack JSON is generated by the macro build; skip if not present.
6369            Err(_) => return,
6370        };
6371        let mut spec: SerializableStackSpec =
6372            serde_json::from_str(&json).expect("ore stack json should deserialize");
6373
6374        if spec.program_specs.is_empty() {
6375            let error = compile_program_modules(spec, None).unwrap_err();
6376            assert!(error.contains("Regenerate the .stack.json"));
6377            return;
6378        }
6379
6380        // Program-only emission must not depend on entities at all.
6381        spec.entities.clear();
6382
6383        let output =
6384            compile_program_modules(spec, None).expect("program-module compilation should succeed");
6385        let file = output.full_file();
6386
6387        // Standalone per-program consts plus the combined map.
6388        assert!(
6389            file.contains("export const ORE = {"),
6390            "ore const missing:\n{}",
6391            file
6392        );
6393        assert!(
6394            file.contains("export const ENTROPY = {"),
6395            "entropy const missing"
6396        );
6397        assert!(
6398            file.contains("export const ORE_STREAM_PROGRAMS = {"),
6399            "combined program map missing"
6400        );
6401        assert!(file.contains("  ore: ORE,"));
6402        assert!(file.contains("  entropy: ENTROPY,"));
6403        assert!(file.contains("export default ORE_STREAM_PROGRAMS;"));
6404        assert!(file.contains("All portable programs from the OreStream stack"));
6405        assert!(file.contains("export const ORE_STREAM_PROGRAM_READS = {"));
6406
6407        // Program bodies keep the full SDK surface...
6408        assert!(file.contains("createInstructionHandler"));
6409        assert!(file.contains("pdas: {"));
6410        assert!(file.contains("addresses: {"));
6411        assert!(file.contains("instructions: {"));
6412        assert!(file.contains("createPreparedInstruction({"));
6413        assert!(file.contains("buildInstruction("));
6414
6415        // ...but nothing stack- or view-shaped is emitted.
6416        assert!(!file.contains("stateView"), "no view helpers expected");
6417        assert!(!file.contains("listView"), "no view helpers expected");
6418        assert!(!file.contains("views:"), "no views block expected");
6419        assert!(!file.contains("endpoints:"), "no endpoints block expected");
6420        assert!(
6421            !file.contains("extendStack"),
6422            "no extension wiring expected"
6423        );
6424    }
6425
6426    #[test]
6427    fn golden_ore_stack_json_emits_typed_state_view_keys() {
6428        let path = concat!(
6429            env!("CARGO_MANIFEST_DIR"),
6430            "/../stacks/ore/.arete/OreStream.stack.json"
6431        );
6432        let json = match std::fs::read_to_string(path) {
6433            Ok(contents) => contents,
6434            // Stack JSON is generated by the macro build; skip if not present.
6435            Err(_) => return,
6436        };
6437        let spec: SerializableStackSpec =
6438            serde_json::from_str(&json).expect("ore stack json should deserialize");
6439
6440        if spec.program_specs.is_empty() {
6441            let error = compile_stack_spec(spec, None).unwrap_err();
6442            assert!(error.contains("Regenerate the .stack.json"));
6443            return;
6444        }
6445
6446        let output = compile_stack_spec(spec, None).expect("ore stack should compile");
6447        let stack = output.stack_definition;
6448
6449        assert!(stack.contains(
6450            "state: stateView<OreRound, { roundId: bigint }>('OreRound/state', ['roundId'])"
6451        ));
6452        assert!(stack.contains(
6453            "state: stateView<OreBoard, { address: string }>('OreBoard/state', ['address'])"
6454        ));
6455        assert!(stack.contains(
6456            "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6457        ));
6458        assert_eq!(
6459            stack.matches(
6460                "state: stateView<OreMiner, { authority: string }>('OreMiner/state', ['authority'])"
6461            )
6462            .count(),
6463            1
6464        );
6465    }
6466
6467    #[test]
6468    fn compile_program_modules_emits_amount_aware_semantic_instruction_wrappers() {
6469        let stack_spec = SerializableStackSpec {
6470            ast_version: CURRENT_AST_VERSION.to_string(),
6471            stack_name: "DemoStream".to_string(),
6472            program_ids: vec!["Prog111".to_string()],
6473            idls: vec![IdlSnapshot {
6474                name: "demo".to_string(),
6475                program_id: Some("Prog111".to_string()),
6476                version: "0.1.0".to_string(),
6477                accounts: vec![],
6478                instructions: vec![IdlInstructionSnapshot {
6479                    name: "deposit".to_string(),
6480                    discriminator: vec![9],
6481                    discriminant: None,
6482                    docs: vec![],
6483                    accounts: vec![],
6484                    args: vec![
6485                        IdlFieldSnapshot {
6486                            name: "amount".to_string(),
6487                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
6488                            amount_hint: None,
6489                        },
6490                        IdlFieldSnapshot {
6491                            name: "mint".to_string(),
6492                            type_: IdlTypeSnapshot::Simple("publicKey".to_string()),
6493                            amount_hint: None,
6494                        },
6495                    ],
6496                }],
6497                types: vec![],
6498                events: vec![],
6499                errors: vec![],
6500                discriminant_size: 1,
6501            }],
6502            program_specs: vec![demo_program_spec()],
6503            entities: vec![],
6504            pdas: BTreeMap::new(),
6505            instructions: vec![InstructionDef {
6506                name: "deposit".to_string(),
6507                discriminator: vec![9],
6508                discriminator_size: 1,
6509                accounts: vec![],
6510                args: vec![
6511                    InstructionArgDef {
6512                        name: "amount".to_string(),
6513                        arg_type: "u64".to_string(),
6514                        docs: vec![],
6515                        amount_hint: Some(InstructionAmountHint {
6516                            decimals_source: AmountDecimalsSource::ArgMint {
6517                                arg_name: "mint".to_string(),
6518                            },
6519                        }),
6520                    },
6521                    InstructionArgDef {
6522                        name: "mint".to_string(),
6523                        arg_type: "solana_pubkey::Pubkey".to_string(),
6524                        docs: vec![],
6525                        amount_hint: None,
6526                    },
6527                ],
6528                errors: vec![],
6529                program_id: Some("Prog111".to_string()),
6530                docs: vec![],
6531            }],
6532            content_hash: None,
6533        };
6534
6535        let output = compile_program_modules(stack_spec, None)
6536            .expect("program-module compilation should succeed");
6537        let file = output.full_file();
6538
6539        assert!(
6540            file.contains("type AmountInput"),
6541            "amount import missing:\n{}",
6542            file
6543        );
6544        assert!(
6545            file.contains("PROGRAM_OPERATION_EXTENSIONS"),
6546            "runtime extension import missing"
6547        );
6548        assert!(
6549            output.imports.contains("type BuildOptions"),
6550            "build options import missing"
6551        );
6552        assert!(
6553            output.imports.contains("type ProgramOperationContext"),
6554            "operation context import missing"
6555        );
6556        assert!(
6557            file.contains("resolveAmountToRaw"),
6558            "amount resolver import missing"
6559        );
6560        assert!(file.contains("export interface DepositSemanticParams"));
6561        assert!(file.contains("build?: BuildOptions;"));
6562        assert!(file.contains("[PROGRAM_OPERATION_EXTENSIONS]: {"));
6563        assert!(file.contains("createOperations(context: ProgramOperationContext)"));
6564        assert!(file
6565            .contains("deposit: instructionOperation(async (params: DepositSemanticParams) => {"));
6566        assert!(file.contains("const { build, amountDecimals, ...rawParams } = params;"));
6567        assert!(file.contains("resolveAmountToRaw(context.chain"));
6568        assert!(file.contains("const instruction = buildInstruction(depositInstruction, {"));
6569        assert!(file.contains("createPreparedInstruction({"));
6570    }
6571
6572    #[test]
6573    fn golden_ore_stack_json_compiles_stack_with_root_helper_namespaces() {
6574        let path = concat!(
6575            env!("CARGO_MANIFEST_DIR"),
6576            "/../stacks/ore/.arete/OreStream.stack.json"
6577        );
6578        let json = match std::fs::read_to_string(path) {
6579            Ok(c) => c,
6580            Err(_) => return,
6581        };
6582        let spec: SerializableStackSpec =
6583            serde_json::from_str(&json).expect("ore stack json should deserialize");
6584
6585        if spec.program_specs.is_empty() {
6586            let error = compile_stack_spec(spec, None).unwrap_err();
6587            assert!(error.contains("Regenerate the .stack.json"));
6588            return;
6589        }
6590
6591        let output = compile_stack_spec(spec, None).expect("stack compilation should succeed");
6592        let file = output.full_file();
6593
6594        assert!(
6595            file.contains("endpoints:"),
6596            "stack endpoints missing:\n{}",
6597            file
6598        );
6599        assert!(
6600            file.contains("programs: {"),
6601            "program block missing:\n{}",
6602            file
6603        );
6604        assert!(
6605            file.contains("addresses: {"),
6606            "root addresses missing:\n{}",
6607            file
6608        );
6609        assert!(
6610            file.contains("instructions: {"),
6611            "program instructions missing:\n{}",
6612            file
6613        );
6614        assert!(
6615            file.contains("buildInstruction("),
6616            "instruction builders missing:\n{}",
6617            file
6618        );
6619    }
6620
6621    #[test]
6622    fn account_codegen_normalizes_raw_keys_and_nested_types() {
6623        let idl_snapshot = IdlSnapshot {
6624            name: "presale".to_string(),
6625            program_id: None,
6626            version: "0.1.0".to_string(),
6627            accounts: vec![IdlAccountSnapshot {
6628                name: "Presale".to_string(),
6629                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6630                docs: vec![],
6631                serialization: None,
6632                fields: vec![
6633                    IdlFieldSnapshot {
6634                        name: "owner".to_string(),
6635                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6636                        amount_hint: None,
6637                    },
6638                    IdlFieldSnapshot {
6639                        name: "total_deposit".to_string(),
6640                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
6641                        amount_hint: None,
6642                    },
6643                    IdlFieldSnapshot {
6644                        name: "optional_authority".to_string(),
6645                        type_: IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
6646                            option: Box::new(IdlTypeSnapshot::Simple("pubkey".to_string())),
6647                        }),
6648                        amount_hint: None,
6649                    },
6650                    IdlFieldSnapshot {
6651                        name: "createKey".to_string(),
6652                        type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6653                        amount_hint: None,
6654                    },
6655                    IdlFieldSnapshot {
6656                        name: "member".to_string(),
6657                        type_: IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
6658                            defined: IdlDefinedInnerSnapshot::Simple("MemberConfig".to_string()),
6659                        }),
6660                        amount_hint: None,
6661                    },
6662                ],
6663                type_def: None,
6664            }],
6665            instructions: vec![],
6666            types: vec![IdlTypeDefSnapshot {
6667                name: "MemberConfig".to_string(),
6668                docs: vec![],
6669                serialization: None,
6670                type_def: IdlTypeDefKindSnapshot::Struct {
6671                    kind: "struct".to_string(),
6672                    fields: vec![
6673                        IdlFieldSnapshot {
6674                            name: "last_updated_at".to_string(),
6675                            type_: IdlTypeSnapshot::Simple("i128".to_string()),
6676                            amount_hint: None,
6677                        },
6678                        IdlFieldSnapshot {
6679                            name: "authority_key".to_string(),
6680                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6681                            amount_hint: None,
6682                        },
6683                    ],
6684                },
6685            }],
6686            events: vec![],
6687            errors: vec![],
6688            discriminant_size: 8,
6689        };
6690
6691        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6692        let account_bigint = bigint_zod();
6693
6694        assert!(artifacts.code.contains("export interface Presale {"));
6695        assert!(
6696            artifacts.code.contains("owner: string;"),
6697            "missing owner field:\n{}",
6698            artifacts.code
6699        );
6700        assert!(
6701            artifacts.code.contains("totalDeposit: bigint;"),
6702            "missing totalDeposit field:\n{}",
6703            artifacts.code
6704        );
6705        assert!(
6706            artifacts.code.contains("optionalAuthority: string | null;"),
6707            "missing optionalAuthority field:\n{}",
6708            artifacts.code
6709        );
6710        assert!(
6711            artifacts.code.contains("createKey: string;"),
6712            "missing createKey field:\n{}",
6713            artifacts.code
6714        );
6715        assert!(
6716            artifacts.code.contains("member: MemberConfig;"),
6717            "missing nested type field:\n{}",
6718            artifacts.code
6719        );
6720        assert!(artifacts.code.contains("export interface MemberConfig {"));
6721        assert!(
6722            artifacts.code.contains("lastUpdatedAt: bigint;"),
6723            "missing nested bigint field:\n{}",
6724            artifacts.code
6725        );
6726        assert!(
6727            artifacts.code.contains("authorityKey: string;"),
6728            "missing nested camelCase field:\n{}",
6729            artifacts.code
6730        );
6731
6732        assert!(artifacts
6733            .code
6734            .contains("export const PresaleSchema = z.object({"));
6735        assert!(
6736            artifacts.code.contains("owner: z.string(),"),
6737            "missing owner schema field:\n{}",
6738            artifacts.code
6739        );
6740        assert!(
6741            artifacts
6742                .code
6743                .contains(&format!("total_deposit: {},", account_bigint)),
6744            "missing total_deposit schema field:\n{}",
6745            artifacts.code
6746        );
6747        assert!(
6748            artifacts
6749                .code
6750                .contains("optional_authority: z.string().nullable(),"),
6751            "missing optional_authority schema field:\n{}",
6752            artifacts.code
6753        );
6754        assert!(
6755            artifacts.code.contains("create_key: z.string(),"),
6756            "missing create_key schema field:\n{}",
6757            artifacts.code
6758        );
6759        assert!(
6760            artifacts
6761                .code
6762                .contains("member: z.lazy(() => MemberConfigSchema),"),
6763            "missing nested schema field:\n{}",
6764            artifacts.code
6765        );
6766        assert!(
6767            artifacts.code.contains("owner: value.owner,"),
6768            "missing owner transform:\n{}",
6769            artifacts.code
6770        );
6771        assert!(
6772            artifacts
6773                .code
6774                .contains("totalDeposit: value.total_deposit,"),
6775            "missing totalDeposit transform:\n{}",
6776            artifacts.code
6777        );
6778        assert!(
6779            artifacts
6780                .code
6781                .contains("optionalAuthority: value.optional_authority,"),
6782            "missing optionalAuthority transform:\n{}",
6783            artifacts.code
6784        );
6785        assert!(
6786            artifacts.code.contains("createKey: value.create_key,"),
6787            "missing createKey transform:\n{}",
6788            artifacts.code
6789        );
6790        assert!(
6791            artifacts.code.contains("member: value.member,"),
6792            "missing nested transform:\n{}",
6793            artifacts.code
6794        );
6795
6796        assert!(artifacts
6797            .code
6798            .contains("export const MemberConfigSchema = z.object({"));
6799        assert!(
6800            artifacts
6801                .code
6802                .contains(&format!("last_updated_at: {},", bigint_zod())),
6803            "missing nested bigint schema field:\n{}",
6804            artifacts.code
6805        );
6806        assert!(
6807            artifacts.code.contains("authority_key: z.string(),"),
6808            "missing nested schema field:\n{}",
6809            artifacts.code
6810        );
6811        assert!(
6812            artifacts
6813                .code
6814                .contains("lastUpdatedAt: value.last_updated_at,"),
6815            "missing nested bigint transform:\n{}",
6816            artifacts.code
6817        );
6818        assert!(
6819            artifacts
6820                .code
6821                .contains("authorityKey: value.authority_key,"),
6822            "missing nested camelCase transform:\n{}",
6823            artifacts.code
6824        );
6825    }
6826
6827    #[test]
6828    fn account_codegen_falls_back_to_same_named_type_def_when_account_fields_are_empty() {
6829        let idl_snapshot = IdlSnapshot {
6830            name: "presale".to_string(),
6831            program_id: None,
6832            version: "0.1.0".to_string(),
6833            accounts: vec![IdlAccountSnapshot {
6834                name: "Presale".to_string(),
6835                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
6836                docs: vec![],
6837                serialization: None,
6838                fields: vec![],
6839                type_def: None,
6840            }],
6841            instructions: vec![],
6842            types: vec![IdlTypeDefSnapshot {
6843                name: "Presale".to_string(),
6844                docs: vec![],
6845                serialization: None,
6846                type_def: IdlTypeDefKindSnapshot::Struct {
6847                    kind: "struct".to_string(),
6848                    fields: vec![
6849                        IdlFieldSnapshot {
6850                            name: "owner".to_string(),
6851                            type_: IdlTypeSnapshot::Simple("pubkey".to_string()),
6852                            amount_hint: None,
6853                        },
6854                        IdlFieldSnapshot {
6855                            name: "total_deposit".to_string(),
6856                            type_: IdlTypeSnapshot::Simple("u64".to_string()),
6857                            amount_hint: None,
6858                        },
6859                    ],
6860                },
6861            }],
6862            events: vec![],
6863            errors: vec![],
6864            discriminant_size: 8,
6865        };
6866
6867        let artifacts = generate_idl_account_artifacts(&[idl_snapshot], &HashSet::new());
6868
6869        assert!(artifacts.code.contains("export interface Presale {"));
6870        assert!(
6871            artifacts.code.contains("owner: string;"),
6872            "missing owner field:\n{}",
6873            artifacts.code
6874        );
6875        assert!(
6876            artifacts.code.contains("totalDeposit: bigint;"),
6877            "missing totalDeposit field:\n{}",
6878            artifacts.code
6879        );
6880        assert!(
6881            artifacts
6882                .code
6883                .contains("export const PresaleSchema = z.object({"),
6884            "missing schema:\n{}",
6885            artifacts.code
6886        );
6887        assert!(
6888            artifacts.code.contains("owner: z.string(),"),
6889            "missing owner schema field:\n{}",
6890            artifacts.code
6891        );
6892        assert!(
6893            artifacts
6894                .code
6895                .contains(&format!("total_deposit: {},", bigint_zod())),
6896            "missing total_deposit schema field:\n{}",
6897            artifacts.code
6898        );
6899        assert!(
6900            artifacts.code.contains("owner: value.owner,"),
6901            "missing owner transform:\n{}",
6902            artifacts.code
6903        );
6904        assert!(
6905            artifacts
6906                .code
6907                .contains("totalDeposit: value.total_deposit,"),
6908            "missing totalDeposit transform:\n{}",
6909            artifacts.code
6910        );
6911    }
6912
6913    #[test]
6914    fn compile_program_modules_rejects_specs_without_idls() {
6915        let stack_spec = SerializableStackSpec {
6916            ast_version: CURRENT_AST_VERSION.to_string(),
6917            stack_name: "Empty".to_string(),
6918            program_ids: vec![],
6919            idls: vec![],
6920            program_specs: vec![],
6921            entities: vec![],
6922            pdas: BTreeMap::new(),
6923            instructions: vec![],
6924            content_hash: None,
6925        };
6926
6927        let error =
6928            compile_program_modules(stack_spec, None).expect_err("no IDLs should be an error");
6929        assert!(error.contains("no IDLs"), "unexpected error: {}", error);
6930    }
6931}