Skip to main content

arete_interpreter/
rust.rs

1use crate::ast::*;
2use crate::typescript_instructions::{
3    dedupe_errors_by_code, normalize_seed_arg_type, split_generic,
4};
5use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
6
7#[derive(Debug, Clone)]
8pub struct RustOutput {
9    pub cargo_toml: String,
10    pub lib_rs: String,
11    pub types_rs: String,
12    pub entity_rs: String,
13    /// Generated program SDK module (`programs.rs`). `None` when the stack
14    /// spec declares no instructions.
15    pub programs_rs: Option<String>,
16}
17
18impl RustOutput {
19    pub fn full_lib(&self) -> String {
20        let mut output = format!(
21            "{}\n\n// types.rs\n{}\n\n// entity.rs\n{}",
22            self.lib_rs, self.types_rs, self.entity_rs
23        );
24        if let Some(programs) = &self.programs_rs {
25            output.push_str("\n\n// programs.rs\n");
26            output.push_str(programs);
27        }
28        output
29    }
30
31    pub fn mod_rs(&self) -> String {
32        self.lib_rs.clone()
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct RustConfig {
38    pub crate_name: String,
39    pub sdk_version: String,
40    pub module_mode: bool,
41    /// WebSocket URL for the stack. If None, generates a placeholder comment.
42    pub url: Option<String>,
43}
44
45impl Default for RustConfig {
46    fn default() -> Self {
47        Self {
48            crate_name: "generated-stack".to_string(),
49            sdk_version: "0.4".to_string(),
50            module_mode: false,
51            url: None,
52        }
53    }
54}
55
56pub fn compile_serializable_spec(
57    spec: SerializableStreamSpec,
58    entity_name: String,
59    config: Option<RustConfig>,
60) -> Result<RustOutput, String> {
61    let config = config.unwrap_or_default();
62    let compiler = RustCompiler::new(spec, entity_name, config);
63    Ok(compiler.compile())
64}
65
66pub fn write_rust_crate(
67    output: &RustOutput,
68    crate_dir: &std::path::Path,
69) -> Result<(), std::io::Error> {
70    std::fs::create_dir_all(crate_dir.join("src"))?;
71    std::fs::write(crate_dir.join("Cargo.toml"), &output.cargo_toml)?;
72    std::fs::write(crate_dir.join("src/lib.rs"), &output.lib_rs)?;
73    std::fs::write(crate_dir.join("src/types.rs"), &output.types_rs)?;
74    std::fs::write(crate_dir.join("src/entity.rs"), &output.entity_rs)?;
75    if let Some(programs) = &output.programs_rs {
76        std::fs::write(crate_dir.join("src/programs.rs"), programs)?;
77    }
78    Ok(())
79}
80
81pub fn write_rust_module(
82    output: &RustOutput,
83    module_dir: &std::path::Path,
84) -> Result<(), std::io::Error> {
85    std::fs::create_dir_all(module_dir)?;
86    std::fs::write(module_dir.join("mod.rs"), output.mod_rs())?;
87    std::fs::write(module_dir.join("types.rs"), &output.types_rs)?;
88    std::fs::write(module_dir.join("entity.rs"), &output.entity_rs)?;
89    if let Some(programs) = &output.programs_rs {
90        std::fs::write(module_dir.join("programs.rs"), programs)?;
91    }
92    Ok(())
93}
94
95/// Runtime envelope a resolved-struct field arrives in. Mirror of the
96/// TypeScript generator's `EventWrapper<T>` / `CaptureWrapper<T>` selection in
97/// `field_type_info_to_typescript` (and of `python::WrapperKind`).
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub(crate) enum WrapperKind {
100    None,
101    Capture,
102    Event,
103}
104
105/// Target paths fed by an `AsCapture` mapping. Mirror of the TypeScript
106/// generator's `is_capture_field` and of `python::capture_field_targets`:
107/// those fields arrive wrapped in a `CaptureWrapper` envelope
108/// (`{timestamp, account_address, data, slot?, signature?}`) rather than as
109/// the bare account struct.
110pub(crate) fn capture_field_targets(spec: &SerializableStreamSpec) -> HashSet<String> {
111    let mut targets = HashSet::new();
112    for handler in &spec.handlers {
113        for mapping in &handler.mappings {
114            if matches!(&mapping.source, MappingSource::AsCapture { .. }) {
115                targets.insert(mapping.target_path.clone());
116            }
117        }
118    }
119    targets
120}
121
122/// Which runtime envelope a resolved-struct field arrives in. Mirror of the
123/// TypeScript generator: `#[capture]`-fed account fields arrive as
124/// `CaptureWrapper<T>` and event/instruction-list fields as `EventWrapper<T>`,
125/// never as the bare struct.
126pub(crate) fn wrapper_kind_for(
127    field: &FieldTypeInfo,
128    resolved: &ResolvedStructType,
129    capture_fields: &HashSet<String>,
130) -> WrapperKind {
131    if resolved.is_event || (resolved.is_instruction && field.is_array) {
132        return WrapperKind::Event;
133    }
134    if resolved.is_account
135        && (capture_fields.contains(&field.field_name)
136            || capture_fields.contains(field.raw_field_name()))
137    {
138        return WrapperKind::Capture;
139    }
140    WrapperKind::None
141}
142
143/// The runtime envelopes every generated `types.rs` carries. Mirrors
144/// `arete_interpreter::{EventWrapper, CaptureWrapper}` and the TypeScript
145/// `EventWrapper<T>` / `CaptureWrapper<T>` interfaces: capture/event-fed fields
146/// arrive wrapped on the wire, so the generated field types name the envelope
147/// and expose the provenance (`timestamp`, `account_address`, `slot`,
148/// `signature`) alongside the decoded `data`.
149const WRAPPER_TYPES: &str = r#"/// Wrapper for event data that includes context metadata.
150/// Events are automatically wrapped in this structure at runtime.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct EventWrapper<T> {
153    /// Unix timestamp when the event was processed.
154    #[serde(default, deserialize_with = "serde_utils::deserialize_i64")]
155    pub timestamp: i64,
156    /// The event-specific data.
157    pub data: T,
158    /// Optional blockchain slot number.
159    #[serde(default, deserialize_with = "serde_utils::deserialize_option_u64")]
160    pub slot: Option<u64>,
161    /// Optional transaction signature.
162    #[serde(default)]
163    pub signature: Option<String>,
164}
165
166impl<T: Default> Default for EventWrapper<T> {
167    fn default() -> Self {
168        Self {
169            timestamp: 0,
170            data: T::default(),
171            slot: None,
172            signature: None,
173        }
174    }
175}
176
177/// Wrapper for account data captured with `#[capture]`, including context
178/// metadata. Captured accounts are automatically wrapped in this structure at
179/// runtime.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct CaptureWrapper<T> {
182    /// Unix timestamp when the account was captured.
183    #[serde(default, deserialize_with = "serde_utils::deserialize_i64")]
184    pub timestamp: i64,
185    /// The account address (base58 encoded public key).
186    #[serde(default)]
187    pub account_address: String,
188    /// The captured account data.
189    pub data: T,
190    /// Optional blockchain slot number.
191    #[serde(default, deserialize_with = "serde_utils::deserialize_option_u64")]
192    pub slot: Option<u64>,
193    /// Optional transaction signature.
194    #[serde(default)]
195    pub signature: Option<String>,
196}
197
198impl<T: Default> Default for CaptureWrapper<T> {
199    fn default() -> Self {
200        Self {
201            timestamp: 0,
202            account_address: String::new(),
203            data: T::default(),
204            slot: None,
205            signature: None,
206        }
207    }
208}
209"#;
210
211/// Rust definitions for the builtin resolver output types a generated SDK can
212/// name, in emission order. Mirror of the `typescript_interface()` blocks the
213/// resolvers in [`crate::resolvers`] register: `SlotHashBytes` (the
214/// `{ bytes }` wire shape of `ResolvedSlotHash`) and `TokenMetadata`. Field
215/// names are the snake_case wire keys the runtime emits, so no rename
216/// attributes are needed.
217///
218/// `KeccakRngValue` is deliberately absent even though it is a registered
219/// resolver output type. It is a `u64`, and TypeScript models it as
220/// `export type KeccakRngValue = string` only because the canonical numeric
221/// rule (docs/internal/sdk-core-api.md ยง2) puts `u64` on the wire as a decimal
222/// string. Rust decodes that back to a real `u64` via
223/// `serde_utils::deserialize_option_*_u64`, so `KeccakRngValue`-typed fields
224/// keep their integer typing instead of degrading to `String`.
225const BUILTIN_RESOLVER_STRUCTS: &[(&str, &str)] = &[
226    (
227        "SlotHashBytes",
228        r#"/// Slot hash resolved by the builtin `SlotHash` resolver.
229#[derive(Debug, Clone, Serialize, Deserialize, Default)]
230pub struct SlotHashBytes {
231    /// 32-byte slot hash.
232    #[serde(default)]
233    pub bytes: Vec<u8>,
234}"#,
235    ),
236    (
237        "TokenMetadata",
238        r#"/// Token metadata resolved by the builtin `TokenMetadata` resolver.
239#[derive(Debug, Clone, Serialize, Deserialize, Default)]
240pub struct TokenMetadata {
241    #[serde(default)]
242    pub mint: String,
243    #[serde(default)]
244    pub name: Option<String>,
245    #[serde(default)]
246    pub symbol: Option<String>,
247    #[serde(default)]
248    pub decimals: Option<u8>,
249    #[serde(default)]
250    pub logo_uri: Option<String>,
251}"#,
252    ),
253];
254
255/// The generated struct name for a builtin resolver output type named by a
256/// field's `inner_type`, or `None`. The registry is the authority (mirror of
257/// `typescript::is_builtin_resolver_type`), narrowed to the types
258/// [`BUILTIN_RESOLVER_STRUCTS`] can express as a Rust struct.
259pub(crate) fn builtin_resolver_struct(inner_type: Option<&str>) -> Option<&'static str> {
260    let inner = inner_type?;
261    if !crate::resolvers::is_resolver_output_type(inner) {
262        return None;
263    }
264    BUILTIN_RESOLVER_STRUCTS
265        .iter()
266        .find(|(name, _)| *name == inner)
267        .map(|(name, _)| *name)
268}
269
270/// Render the builtin resolver structs a generated `types.rs` actually
271/// references. Each block is followed by a blank line.
272fn render_builtin_resolver_structs(used: &BTreeSet<&'static str>) -> String {
273    let mut output = String::new();
274    for (name, definition) in BUILTIN_RESOLVER_STRUCTS {
275        if used.contains(name) {
276            output.push_str(definition);
277            output.push_str("\n\n");
278        }
279    }
280    output
281}
282
283/// Map the element of a `Vec<T>` scalar array to its Rust primitive. Mirror of
284/// `typescript::typescript_scalar_array_element` and
285/// `python::py_scalar_array_element`; accepts both stored forms of the inner
286/// type (`"Vec < f64 >"` and the bare `"f64"`) and returns `None` for
287/// non-scalar elements.
288fn rust_scalar_array_element(inner_type: &str) -> Option<&'static str> {
289    let trimmed = inner_type.trim();
290    let element = trimmed
291        .strip_prefix("Vec <")
292        .and_then(|rest| rest.strip_suffix('>'))
293        .or_else(|| {
294            trimmed
295                .strip_prefix("Vec<")
296                .and_then(|rest| rest.strip_suffix('>'))
297        })
298        .map(str::trim)
299        .unwrap_or(trimmed);
300    match element {
301        "f32" | "f64" => Some("f64"),
302        "bool" => Some("bool"),
303        "String" | "&str" | "str" => Some("String"),
304        _ => None,
305    }
306}
307
308/// Rust type plus `serde_utils` requirement for a non-resolved (scalar /
309/// scalar-array) field. Type and `#[serde(...)]` attribute are derived from
310/// one place so an integer vector can never be typed `Vec<u64>` while its
311/// deserializer stays scalar (or vice versa).
312struct RustScalarShape {
313    /// The bare Rust type, before the patch `Option<..>` wrapping.
314    rust_type: String,
315    /// Normalized integer kind whose `serde_utils` deserializer this field
316    /// needs, or `None` for a plain `#[serde(default)]`.
317    integer_kind: Option<&'static str>,
318    /// Whether that deserializer must be the `_vec_` variant.
319    is_vec: bool,
320}
321
322/// Shape of a non-resolved field. Shared by the entity-section path and the
323/// IDL `ResolvedField` path so the same on-chain array is typed identically
324/// whichever way it is reached (mirror of `python::py_scalar_field_shape`).
325///
326/// `Vec<u64>`-shaped fields are stored as `BaseType::Array` with an explicit
327/// `integer_kind`, so the integer check has to consult `integer_kind` and not
328/// just `base_type`. The guard stays tighter than the TypeScript one
329/// (`BaseType::Array` only, never "any field carrying an `integer_kind`") so
330/// `BaseType::Binary` fields keep their `Vec<u8>`.
331fn rust_scalar_field_shape(
332    base_type: &BaseType,
333    integer_kind: Option<IntegerKind>,
334    is_array: bool,
335    inner_type: Option<&str>,
336    rust_type_name: &str,
337) -> RustScalarShape {
338    if is_array && matches!(base_type, BaseType::Array) {
339        if let Some(kind) = integer_kind {
340            let kind = normalized_integer_kind_of(kind);
341            return RustScalarShape {
342                rust_type: format!("Vec<{kind}>"),
343                integer_kind: Some(kind),
344                is_vec: true,
345            };
346        }
347        if let Some(element) = inner_type.and_then(rust_scalar_array_element) {
348            return RustScalarShape {
349                rust_type: format!("Vec<{element}>"),
350                integer_kind: None,
351                is_vec: false,
352            };
353        }
354    }
355
356    // Only integer and timestamp types need the string-or-number treatment.
357    let kind = match base_type {
358        BaseType::Integer => Some(normalized_integer_kind(rust_type_name)),
359        BaseType::Timestamp => Some("i64"),
360        _ => None,
361    };
362    let is_vec = is_array && !matches!(base_type, BaseType::Array);
363    let base = base_type_to_rust(base_type, rust_type_name);
364    RustScalarShape {
365        rust_type: if is_vec { format!("Vec<{base}>") } else { base },
366        integer_kind: kind,
367        is_vec,
368    }
369}
370
371/// The `serde_utils::deserialize_*` function a shape needs, or `None` when a
372/// plain `#[serde(default)]` suffices.
373fn deserialize_with_for_shape(shape: &RustScalarShape, is_optional: bool) -> Option<String> {
374    let kind = shape.integer_kind?;
375    Some(match (is_optional, shape.is_vec) {
376        (false, false) => format!("serde_utils::deserialize_option_{kind}"),
377        (true, false) => format!("serde_utils::deserialize_option_option_{kind}"),
378        (false, true) => format!("serde_utils::deserialize_option_vec_{kind}"),
379        (true, true) => format!("serde_utils::deserialize_option_option_vec_{kind}"),
380    })
381}
382
383fn base_type_to_rust(base_type: &BaseType, rust_type_name: &str) -> String {
384    match base_type {
385        BaseType::Integer => normalized_integer_kind(rust_type_name).to_string(),
386        BaseType::Float => "f64".to_string(),
387        BaseType::String => "String".to_string(),
388        BaseType::Boolean => "bool".to_string(),
389        BaseType::Timestamp => "i64".to_string(),
390        BaseType::Binary => "Vec<u8>".to_string(),
391        BaseType::Pubkey => "String".to_string(),
392        BaseType::Array => "Vec<serde_json::Value>".to_string(),
393        BaseType::Object => "serde_json::Value".to_string(),
394        BaseType::Any => "serde_json::Value".to_string(),
395    }
396}
397
398pub(crate) struct RustCompiler {
399    spec: SerializableStreamSpec,
400    entity_name: String,
401    config: RustConfig,
402    /// Field targets fed by an `AsCapture` mapping in this entity's handlers.
403    capture_fields: HashSet<String>,
404}
405
406impl RustCompiler {
407    pub(crate) fn new(
408        spec: SerializableStreamSpec,
409        entity_name: String,
410        config: RustConfig,
411    ) -> Self {
412        let capture_fields = capture_field_targets(&spec);
413        Self {
414            spec,
415            entity_name,
416            config,
417            capture_fields,
418        }
419    }
420
421    fn compile(&self) -> RustOutput {
422        RustOutput {
423            cargo_toml: self.generate_cargo_toml(),
424            lib_rs: self.generate_lib_rs(),
425            types_rs: self.generate_types_rs(),
426            entity_rs: self.generate_entity_rs(),
427            programs_rs: None,
428        }
429    }
430
431    fn generate_cargo_toml(&self) -> String {
432        format!(
433            r#"[package]
434name = "{}"
435version = "0.1.0"
436edition = "2021"
437
438[dependencies]
439arete-sdk = {{ package = "arete-a4-sdk", version = "{}" }}
440serde = {{ version = "1", features = ["derive"] }}
441serde_json = "1"
442"#,
443            self.config.crate_name, self.config.sdk_version
444        )
445    }
446
447    fn generate_lib_rs(&self) -> String {
448        let stack_name = self.derive_stack_name();
449        let entity_name = &self.entity_name;
450
451        format!(
452            r#"mod entity;
453mod types;
454
455pub use entity::{{{stack_name}Stack, {stack_name}StackViews, {entity_name}EntityViews}};
456pub use types::*;
457
458pub use arete_sdk::{{ConnectionState, Arete, Stack, Update, Views}};
459"#,
460            stack_name = stack_name,
461            entity_name = entity_name
462        )
463    }
464
465    fn generate_types_rs(&self) -> String {
466        let mut output = String::new();
467        output.push_str("use serde::{Deserialize, Serialize};\n");
468        output.push_str("use arete_sdk::serde_utils;\n\n");
469
470        let resolved_name_map = self.build_resolved_type_name_map();
471        let mut generated = HashSet::new();
472
473        for section in &self.spec.sections {
474            if !Self::is_root_section(&section.name)
475                && section.fields.iter().any(|field| field.emit)
476                && generated.insert(section.name.clone())
477            {
478                output.push_str(&self.generate_struct_for_section(section, &resolved_name_map));
479                output.push_str("\n\n");
480            }
481        }
482
483        output.push_str(&self.generate_main_entity_struct(&resolved_name_map));
484        output.push_str(&self.generate_resolved_types(&resolved_name_map, &mut generated, None));
485
486        let builtins = render_builtin_resolver_structs(&self.used_builtin_resolver_types());
487        if !builtins.is_empty() {
488            output.push_str("\n\n");
489            output.push_str(builtins.trim_end());
490        }
491
492        output.push_str(&self.generate_wrapper_types());
493
494        output
495    }
496
497    pub(crate) fn generate_struct_for_section(
498        &self,
499        section: &EntitySection,
500        resolved_name_map: &HashMap<String, String>,
501    ) -> String {
502        let struct_name = format!("{}{}", self.entity_name, to_pascal_case(&section.name));
503        let mut fields = Vec::new();
504
505        for field in &section.fields {
506            if !field.emit {
507                continue;
508            }
509            let field_name = to_snake_case(&field.field_name);
510            let rust_type = self.field_type_to_rust(field, &section.name, resolved_name_map);
511            let serde_attr = self.serde_attr_for_field(field, &section.name);
512
513            fields.push(format!(
514                "    {}\n    pub {}: {},",
515                serde_attr, field_name, rust_type
516            ));
517        }
518
519        format!(
520            "#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct {} {{\n{}\n}}",
521            struct_name,
522            fields.join("\n")
523        )
524    }
525
526    pub(crate) fn is_root_section(name: &str) -> bool {
527        name.eq_ignore_ascii_case("root")
528    }
529
530    pub(crate) fn generate_main_entity_struct(
531        &self,
532        resolved_name_map: &HashMap<String, String>,
533    ) -> String {
534        let mut fields = Vec::new();
535
536        for section in &self.spec.sections {
537            if !Self::is_root_section(&section.name)
538                && section.fields.iter().any(|field| field.emit)
539            {
540                let field_name = to_snake_case(&section.name);
541                let type_name = format!("{}{}", self.entity_name, to_pascal_case(&section.name));
542                fields.push(format!(
543                    "    #[serde(default)]\n    pub {}: {},",
544                    field_name, type_name
545                ));
546            }
547        }
548
549        for section in &self.spec.sections {
550            if Self::is_root_section(&section.name) {
551                for field in &section.fields {
552                    if !field.emit {
553                        continue;
554                    }
555                    let field_name = to_snake_case(&field.field_name);
556                    let rust_type =
557                        self.field_type_to_rust(field, &section.name, resolved_name_map);
558                    let serde_attr = self.serde_attr_for_field(field, &section.name);
559                    fields.push(format!(
560                        "    {}\n    pub {}: {},",
561                        serde_attr, field_name, rust_type
562                    ));
563                }
564            }
565        }
566
567        format!(
568            "#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct {} {{\n{}\n}}",
569            self.entity_name,
570            fields.join("\n")
571        )
572    }
573
574    pub(crate) fn generate_resolved_types(
575        &self,
576        resolved_name_map: &HashMap<String, String>,
577        generated: &mut HashSet<String>,
578        mut account_structs: Option<&mut BTreeMap<String, String>>,
579    ) -> String {
580        let mut output = String::new();
581
582        for section in &self.spec.sections {
583            for field in &section.fields {
584                if !field.emit {
585                    continue;
586                }
587                if let Some(resolved) = &field.resolved_type {
588                    let emitted_name = self.resolved_type_to_rust_name(resolved, resolved_name_map);
589                    if generated.insert(emitted_name.clone()) {
590                        if resolved.is_account && !resolved.is_enum {
591                            if let Some(map) = account_structs.as_deref_mut() {
592                                map.entry(resolved.type_name.clone())
593                                    .or_insert_with(|| emitted_name.clone());
594                            }
595                        }
596                        output.push_str("\n\n");
597                        output.push_str(&self.generate_resolved_struct(resolved, &emitted_name));
598                    }
599                }
600            }
601        }
602
603        output
604    }
605
606    fn generate_resolved_struct(
607        &self,
608        resolved: &ResolvedStructType,
609        emitted_name: &str,
610    ) -> String {
611        if resolved.is_enum {
612            let variants: Vec<String> = resolved
613                .enum_variants
614                .iter()
615                .map(|v| format!("    {},", to_pascal_case(v)))
616                .collect();
617
618            format!(
619                "#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub enum {} {{\n{}\n}}",
620                emitted_name,
621                variants.join("\n")
622            )
623        } else {
624            let fields: Vec<String> = resolved
625                .fields
626                .iter()
627                .map(|f| {
628                    let rust_type = self.resolved_field_to_rust(f);
629                    let serde_attr = self.serde_attr_for_resolved_field(f);
630                    format!(
631                        "    {}\n    pub {}: {},",
632                        serde_attr,
633                        to_snake_case(&f.field_name),
634                        rust_type
635                    )
636                })
637                .collect();
638
639            format!(
640                "#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct {} {{\n{}\n}}",
641                emitted_name,
642                fields.join("\n")
643            )
644        }
645    }
646
647    fn generate_wrapper_types(&self) -> String {
648        format!("\n\n{WRAPPER_TYPES}")
649    }
650
651    fn generate_entity_rs(&self) -> String {
652        let entity_name = &self.entity_name;
653        let stack_name = self.derive_stack_name();
654        let stack_name_kebab = to_kebab_case(entity_name);
655        let entity_snake = to_snake_case(entity_name);
656
657        let types_import = if self.config.module_mode {
658            "super::types"
659        } else {
660            "crate::types"
661        };
662
663        // Generate URL line - either actual URL or placeholder comment
664        let url_impl = match &self.config.url {
665            Some(url) => format!(
666                r#"fn url() -> &'static str {{
667        "{}"
668    }}"#,
669                url
670            ),
671            None => r#"fn url() -> &'static str {
672        "" // TODO: Set URL after first deployment in arete.toml
673    }"#
674            .to_string(),
675        };
676
677        let entity_views = self.generate_entity_views_struct();
678
679        format!(
680            r#"use {types_import}::{entity_name};
681use arete_sdk::{{Stack, StateView, ViewBuilder, ViewHandle, Views}};
682
683pub struct {stack_name}Stack;
684
685impl Stack for {stack_name}Stack {{
686    type Views = {stack_name}StackViews;
687    type Programs = ();
688
689    fn name() -> &'static str {{
690        "{stack_name_kebab}"
691    }}
692
693    {url_impl}
694}}
695
696pub struct {stack_name}StackViews {{
697    pub {entity_snake}: {entity_name}EntityViews,
698}}
699
700impl Views for {stack_name}StackViews {{
701    fn from_builder(builder: ViewBuilder) -> Self {{
702        Self {{
703            {entity_snake}: {entity_name}EntityViews {{ builder }},
704        }}
705    }}
706}}
707{entity_views}"#,
708            types_import = types_import,
709            entity_name = entity_name,
710            stack_name = stack_name,
711            stack_name_kebab = stack_name_kebab,
712            entity_snake = entity_snake,
713            url_impl = url_impl,
714            entity_views = entity_views
715        )
716    }
717
718    fn generate_entity_views_struct(&self) -> String {
719        let entity_name = &self.entity_name;
720
721        let derived: Vec<_> = self
722            .spec
723            .views
724            .iter()
725            .filter(|v| {
726                !v.id.ends_with("/state")
727                    && !v.id.ends_with("/list")
728                    && v.id.starts_with(entity_name)
729            })
730            .collect();
731
732        let mut derived_methods = String::new();
733        for view in &derived {
734            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
735            let method_name = to_snake_case(view_name);
736
737            derived_methods.push_str(&format!(
738                r#"
739    pub fn {method_name}(&self) -> ViewHandle<{entity_name}> {{
740        self.builder.view("{view_id}")
741    }}
742"#,
743                method_name = method_name,
744                entity_name = entity_name,
745                view_id = view.id
746            ));
747        }
748
749        format!(
750            r#"
751pub struct {entity_name}EntityViews {{
752    builder: ViewBuilder,
753}}
754
755impl {entity_name}EntityViews {{
756    pub fn state(&self) -> StateView<{entity_name}> {{
757        StateView::new(
758            self.builder.connection().clone(),
759            self.builder.store().clone(),
760            "{entity_name}/state".to_string(),
761            self.builder.initial_data_timeout(),
762        )
763    }}
764
765    pub fn list(&self) -> ViewHandle<{entity_name}> {{
766        self.builder.view("{entity_name}/list")
767    }}
768{derived_methods}}}"#,
769            entity_name = entity_name,
770            derived_methods = derived_methods
771        )
772    }
773
774    /// Derive stack name from entity name.
775    /// E.g., "OreRound" -> "Ore", "PumpfunToken" -> "Pumpfun"
776    fn derive_stack_name(&self) -> String {
777        let entity_name = &self.entity_name;
778
779        // Common suffixes to strip
780        let suffixes = ["Round", "Token", "Game", "State", "Entity", "Data"];
781
782        for suffix in suffixes {
783            if entity_name.ends_with(suffix) && entity_name.len() > suffix.len() {
784                return entity_name[..entity_name.len() - suffix.len()].to_string();
785            }
786        }
787
788        // If no suffix matched, use the full entity name
789        entity_name.clone()
790    }
791
792    /// Generate Rust type for a field.
793    ///
794    /// All fields are wrapped in Option<T> because we receive partial patches,
795    /// so any field may not yet be present.
796    ///
797    /// - Non-optional spec fields become `Option<T>`:
798    ///   - `None` = not yet received in any patch
799    ///   - `Some(value)` = has value
800    ///
801    /// - Optional spec fields become `Option<Option<T>>`:
802    ///   - `None` = not yet received in any patch
803    ///   - `Some(None)` = explicitly set to null
804    ///   - `Some(Some(value))` = has value
805    fn field_type_to_rust(
806        &self,
807        field: &FieldTypeInfo,
808        section_name: &str,
809        resolved_name_map: &HashMap<String, String>,
810    ) -> String {
811        // Fields backed by a resolved IDL struct are typed against the emitted
812        // struct, wrapped in the runtime envelope they actually arrive in.
813        // Mirror of `typescript::field_type_info_to_typescript`.
814        let typed = if let Some(resolved) = &field.resolved_type {
815            let name = self.resolved_type_to_rust_name(resolved, resolved_name_map);
816            let element = match wrapper_kind_for(field, resolved, &self.capture_fields) {
817                WrapperKind::None => name,
818                WrapperKind::Capture => format!("CaptureWrapper<{}>", name),
819                WrapperKind::Event => format!("EventWrapper<{}>", name),
820            };
821            if field.is_array {
822                format!("Vec<{}>", element)
823            } else {
824                element
825            }
826        } else if let Some(builtin) = self.builtin_type_for_field(section_name, field) {
827            // Builtin resolver outputs are typed against the generated struct.
828            // Mirror of `typescript::field_type_info_to_typescript`.
829            if field.is_array {
830                format!("Vec<{}>", builtin)
831            } else {
832                builtin.to_string()
833            }
834        } else {
835            self.scalar_shape_for_field(field).rust_type
836        };
837
838        // All fields wrapped in Option since we receive patches
839        // Optional spec fields get Option<Option<T>> to distinguish "not received" from "explicitly null"
840        if field.is_optional {
841            format!("Option<Option<{}>>", typed)
842        } else {
843            format!("Option<{}>", typed)
844        }
845    }
846
847    /// The builtin resolver struct a section field is typed against, if any.
848    ///
849    /// Mirror of the TypeScript generator's "effective field info" override in
850    /// `add_unmapped_fields`: a computed field keeps the *user's* declared Rust
851    /// type in the section (`ResolvedSlotHash`), and only the `field_mappings`
852    /// entry records the resolver output type (`SlotHashBytes`), so both have
853    /// to be consulted.
854    fn builtin_type_for_field(
855        &self,
856        section_name: &str,
857        field: &FieldTypeInfo,
858    ) -> Option<&'static str> {
859        if let Some(name) = builtin_resolver_struct(field.inner_type.as_deref()) {
860            return Some(name);
861        }
862        let field_path = format!("{}.{}", section_name, field.field_name);
863        self.spec
864            .field_mappings
865            .get(&field_path)
866            .and_then(|mapping| builtin_resolver_struct(mapping.inner_type.as_deref()))
867    }
868
869    /// Builtin resolver structs referenced by this entity's emitted fields.
870    pub(crate) fn used_builtin_resolver_types(&self) -> BTreeSet<&'static str> {
871        let mut used = BTreeSet::new();
872        for section in &self.spec.sections {
873            for field in &section.fields {
874                if !field.emit || field.resolved_type.is_some() {
875                    continue;
876                }
877                if let Some(name) = self.builtin_type_for_field(&section.name, field) {
878                    used.insert(name);
879                }
880            }
881        }
882        used
883    }
884
885    fn scalar_shape_for_field(&self, field: &FieldTypeInfo) -> RustScalarShape {
886        rust_scalar_field_shape(
887            &field.base_type,
888            field.effective_integer_kind(),
889            field.is_array,
890            field
891                .inner_type
892                .as_deref()
893                .or(Some(field.rust_type_name.as_str())),
894            &field.rust_type_name,
895        )
896    }
897
898    fn scalar_shape_for_resolved_field(&self, field: &ResolvedField) -> RustScalarShape {
899        rust_scalar_field_shape(
900            &field.base_type,
901            field.effective_integer_kind(),
902            field.is_array,
903            Some(field.field_type.as_str()),
904            &field.field_type,
905        )
906    }
907
908    /// Return the `#[serde(...)]` attribute for a field.
909    /// Integer fields get a `deserialize_with` pointing to the appropriate
910    /// `serde_utils` function so that string-encoded big integers are handled.
911    fn serde_attr_for_field(&self, field: &FieldTypeInfo, section_name: &str) -> String {
912        if field.resolved_type.is_some()
913            || self.builtin_type_for_field(section_name, field).is_some()
914        {
915            return "#[serde(default)]".to_string();
916        }
917        let shape = self.scalar_shape_for_field(field);
918        match deserialize_with_for_shape(&shape, field.is_optional) {
919            Some(deser_fn) => format!("#[serde(default, deserialize_with = \"{}\")]", deser_fn),
920            None => "#[serde(default)]".to_string(),
921        }
922    }
923
924    /// Same as `serde_attr_for_field` but for resolved struct fields.
925    fn serde_attr_for_resolved_field(&self, field: &ResolvedField) -> String {
926        let shape = self.scalar_shape_for_resolved_field(field);
927        match deserialize_with_for_shape(&shape, field.is_optional) {
928            Some(deser_fn) => format!("#[serde(default, deserialize_with = \"{}\")]", deser_fn),
929            None => "#[serde(default)]".to_string(),
930        }
931    }
932
933    fn resolved_field_to_rust(&self, field: &ResolvedField) -> String {
934        let typed = self.scalar_shape_for_resolved_field(field).rust_type;
935
936        if field.is_optional {
937            format!("Option<Option<{}>>", typed)
938        } else {
939            format!("Option<{}>", typed)
940        }
941    }
942
943    fn build_resolved_type_name_map(&self) -> HashMap<String, String> {
944        let mut reserved_names = HashSet::from([
945            self.entity_name.clone(),
946            "EventWrapper".to_string(),
947            "CaptureWrapper".to_string(),
948        ]);
949
950        // Builtin resolver structs share the `types.rs` namespace, so a
951        // same-named IDL type has to be renamed around them. Mirror of the
952        // TypeScript generator reserving `TokenMetadata`.
953        for (name, _) in BUILTIN_RESOLVER_STRUCTS {
954            reserved_names.insert((*name).to_string());
955        }
956
957        for section in &self.spec.sections {
958            if !Self::is_root_section(&section.name)
959                && section.fields.iter().any(|field| field.emit)
960            {
961                reserved_names.insert(format!(
962                    "{}{}",
963                    self.entity_name,
964                    to_pascal_case(&section.name)
965                ));
966            }
967        }
968
969        let mut resolved_name_map = HashMap::new();
970
971        for section in &self.spec.sections {
972            for field in &section.fields {
973                if !field.emit {
974                    continue;
975                }
976
977                let Some(resolved) = &field.resolved_type else {
978                    continue;
979                };
980
981                if resolved_name_map.contains_key(&resolved.type_name) {
982                    continue;
983                }
984
985                let emitted_name = unique_resolved_type_name(resolved, &mut reserved_names);
986                resolved_name_map.insert(resolved.type_name.clone(), emitted_name);
987            }
988        }
989
990        resolved_name_map
991    }
992
993    fn resolved_type_to_rust_name(
994        &self,
995        resolved: &ResolvedStructType,
996        resolved_name_map: &HashMap<String, String>,
997    ) -> String {
998        resolved_name_map
999            .get(&resolved.type_name)
1000            .cloned()
1001            .unwrap_or_else(|| to_pascal_case(&resolved.type_name))
1002    }
1003}
1004
1005fn unique_resolved_type_name(
1006    resolved: &ResolvedStructType,
1007    reserved_names: &mut HashSet<String>,
1008) -> String {
1009    let base_name = to_pascal_case(&resolved.type_name);
1010    if reserved_names.insert(base_name.clone()) {
1011        return base_name;
1012    }
1013
1014    let suffix = if resolved.is_account {
1015        "Account"
1016    } else if resolved.is_event {
1017        "Event"
1018    } else if resolved.is_instruction {
1019        "Instruction"
1020    } else {
1021        "Type"
1022    };
1023
1024    let preferred = format!("{}{}", base_name, suffix);
1025    if reserved_names.insert(preferred.clone()) {
1026        return preferred;
1027    }
1028
1029    let mut index = 2;
1030    loop {
1031        let candidate = format!("{}{}{}", base_name, suffix, index);
1032        if reserved_names.insert(candidate.clone()) {
1033            return candidate;
1034        }
1035        index += 1;
1036    }
1037}
1038
1039/// [`normalized_integer_kind`] for an already-classified [`IntegerKind`].
1040/// Kept byte-for-byte equivalent to the string-sniffing version: only
1041/// `u64`/`i64`/`u32`/`i32` have `serde_utils` deserializers, so unsigned small
1042/// ints widen to `u64` and everything else widens to `i64`.
1043fn normalized_integer_kind_of(kind: IntegerKind) -> &'static str {
1044    match kind {
1045        IntegerKind::U64 => "u64",
1046        IntegerKind::U32 => "u32",
1047        IntegerKind::I32 => "i32",
1048        IntegerKind::U8 | IntegerKind::U16 | IntegerKind::Usize => "u64",
1049        // Signed small ints (i16/i8/isize) and the 128-bit kinds widen to i64.
1050        _ => "i64",
1051    }
1052}
1053
1054fn normalized_integer_kind(rust_type_name: &str) -> &'static str {
1055    if rust_type_name.contains("u64") {
1056        "u64"
1057    } else if rust_type_name.contains("i64") {
1058        "i64"
1059    } else if rust_type_name.contains("u32") {
1060        "u32"
1061    } else if rust_type_name.contains("i32") {
1062        "i32"
1063    } else if rust_type_name.contains("u16")
1064        || rust_type_name.contains("u8")
1065        || rust_type_name.contains("usize")
1066    {
1067        "u64"
1068    } else {
1069        // Signed small ints (i16/i8/isize) and anything unknown widen to i64.
1070        "i64"
1071    }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076    use super::*;
1077    use std::collections::BTreeMap;
1078
1079    fn identity_spec() -> IdentitySpec {
1080        IdentitySpec {
1081            primary_keys: vec!["id.address".to_string()],
1082            lookup_indexes: vec![],
1083        }
1084    }
1085
1086    #[test]
1087    fn rust_generator_renames_account_types_on_collision() {
1088        let plan_field = FieldTypeInfo {
1089            field_name: "plan".to_string(),
1090            raw_name: Some("plan".to_string()),
1091            canonical_name: Some("plan".to_string()),
1092            rust_type_name: "Option<serde_json::Value>".to_string(),
1093            base_type: BaseType::Object,
1094            integer_kind: None,
1095            is_optional: false,
1096            is_array: false,
1097            inner_type: Some("Value".to_string()),
1098            source_path: None,
1099            resolved_type: Some(ResolvedStructType {
1100                type_name: "plan".to_string(),
1101                fields: vec![],
1102                is_instruction: false,
1103                is_account: true,
1104                is_event: false,
1105                is_enum: false,
1106                enum_variants: vec![],
1107            }),
1108            emit: true,
1109        };
1110
1111        let spec = SerializableStreamSpec {
1112            ast_version: CURRENT_AST_VERSION.to_string(),
1113            state_name: "Plan".to_string(),
1114            program_id: None,
1115            idl: None,
1116            identity: identity_spec(),
1117            handlers: vec![],
1118            sections: vec![
1119                EntitySection {
1120                    name: "id".to_string(),
1121                    fields: vec![FieldTypeInfo::new(
1122                        "address".to_string(),
1123                        "String".to_string(),
1124                    )],
1125                    is_nested_struct: false,
1126                    parent_field: None,
1127                },
1128                EntitySection {
1129                    name: "plan".to_string(),
1130                    fields: vec![plan_field],
1131                    is_nested_struct: false,
1132                    parent_field: None,
1133                },
1134            ],
1135            field_mappings: BTreeMap::new(),
1136            resolver_hooks: vec![],
1137            instruction_hooks: vec![],
1138            resolver_specs: vec![],
1139            computed_fields: vec![],
1140            computed_field_specs: vec![],
1141            content_hash: None,
1142            views: vec![],
1143        };
1144
1145        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
1146            .expect("rust sdk generation should succeed");
1147
1148        // The resolved struct is renamed away from the entity struct, and the
1149        // field is typed against the renamed struct (no `AsCapture` mapping
1150        // feeds it, so it stays a bare struct โ€” see
1151        // `rust_generator_wraps_capture_and_event_fields`).
1152        assert!(output.types_rs.contains("pub struct PlanAccount"));
1153        assert!(output.types_rs.contains("pub plan: Option<PlanAccount>"));
1154        assert!(!output
1155            .types_rs
1156            .contains("pub plan: Option<serde_json::Value>"));
1157        assert!(
1158            !output.types_rs.contains("pub struct Plan {\n    #[serde(default, deserialize_with = \"serde_utils::deserialize_option_u64\")]\n    pub discriminator")
1159        );
1160    }
1161
1162    #[test]
1163    fn rust_generator_keeps_unsigned_numeric_fields_unsigned() {
1164        let spec = SerializableStreamSpec {
1165            ast_version: CURRENT_AST_VERSION.to_string(),
1166            state_name: "Plan".to_string(),
1167            program_id: None,
1168            idl: None,
1169            identity: identity_spec(),
1170            handlers: vec![],
1171            sections: vec![
1172                EntitySection {
1173                    name: "id".to_string(),
1174                    fields: vec![FieldTypeInfo::new(
1175                        "address".to_string(),
1176                        "String".to_string(),
1177                    )],
1178                    is_nested_struct: false,
1179                    parent_field: None,
1180                },
1181                EntitySection {
1182                    name: "state".to_string(),
1183                    fields: vec![FieldTypeInfo::new(
1184                        "status".to_string(),
1185                        "Option<u8>".to_string(),
1186                    )],
1187                    is_nested_struct: false,
1188                    parent_field: None,
1189                },
1190            ],
1191            field_mappings: BTreeMap::new(),
1192            resolver_hooks: vec![],
1193            instruction_hooks: vec![],
1194            resolver_specs: vec![],
1195            computed_fields: vec![],
1196            computed_field_specs: vec![],
1197            content_hash: None,
1198            views: vec![],
1199        };
1200
1201        let output = compile_serializable_spec(spec, "Plan".to_string(), None)
1202            .expect("rust sdk generation should succeed");
1203
1204        assert!(
1205            output.types_rs.contains("pub status: Option<Option<u64>>"),
1206            "expected unsigned optional field, got:\n{}",
1207            output.types_rs
1208        );
1209    }
1210
1211    /// Scalar arrays must land on a real Rust element type. `Vec<u64>`-shaped
1212    /// fields reach the generator as `BaseType::Array` + `integer_kind`, so the
1213    /// integer check has to consult `integer_kind` (TypeScript emits
1214    /// `bigint[]`, Python `List[int]`); non-integer scalar arrays keep their
1215    /// element type instead of degrading to `Vec<serde_json::Value>`.
1216    /// Rust twin of `python::tests::python_generator_converts_u64_arrays`.
1217    #[test]
1218    fn rust_generator_types_scalar_arrays() {
1219        let mut entity = minimal_entity("OreRound");
1220        entity.sections.push(EntitySection {
1221            name: "state".to_string(),
1222            fields: vec![
1223                FieldTypeInfo::new(
1224                    "deployed_per_square".to_string(),
1225                    "Option<Vec<u64>>".to_string(),
1226                ),
1227                FieldTypeInfo::new(
1228                    "deployed_per_square_ui".to_string(),
1229                    "Option<Vec<f64>>".to_string(),
1230                ),
1231                FieldTypeInfo::new("flags".to_string(), "Option<Vec<bool>>".to_string()),
1232                FieldTypeInfo::new("labels".to_string(), "Option<Vec<String>>".to_string()),
1233                FieldTypeInfo::new("resolved_seed".to_string(), "Option<Vec<u8>>".to_string()),
1234                // A `#[binary]` blob keeps `Vec<u8>`: the integer guard is
1235                // `BaseType::Array`-only, never "any field with an
1236                // `integer_kind`".
1237                FieldTypeInfo::new("payload".to_string(), "Option<Vec<u8>>".to_string()),
1238            ],
1239            is_nested_struct: false,
1240            parent_field: None,
1241        });
1242        // The interpreter records the element kind explicitly for `Vec<u64>`.
1243        for field in &mut entity.sections[1].fields {
1244            match field.field_name.as_str() {
1245                "deployed_per_square" => {
1246                    field.base_type = BaseType::Array;
1247                    field.integer_kind = Some(IntegerKind::U64);
1248                    field.is_array = true;
1249                    field.inner_type = Some("Vec < u64 >".to_string());
1250                }
1251                "resolved_seed" => {
1252                    field.base_type = BaseType::Array;
1253                    field.integer_kind = Some(IntegerKind::U8);
1254                    field.is_array = true;
1255                    field.inner_type = Some("Vec < u8 >".to_string());
1256                }
1257                "deployed_per_square_ui" => {
1258                    field.base_type = BaseType::Array;
1259                    field.is_array = true;
1260                    field.inner_type = Some("Vec < f64 >".to_string());
1261                }
1262                "flags" => {
1263                    field.base_type = BaseType::Array;
1264                    field.is_array = true;
1265                    field.inner_type = Some("Vec < bool >".to_string());
1266                }
1267                "labels" => {
1268                    field.base_type = BaseType::Array;
1269                    field.is_array = true;
1270                    field.inner_type = Some("Vec < String >".to_string());
1271                }
1272                "payload" => {
1273                    field.base_type = BaseType::Binary;
1274                    field.integer_kind = Some(IntegerKind::U8);
1275                    field.is_array = false;
1276                    field.inner_type = Some("Vec < u8 >".to_string());
1277                }
1278                _ => {}
1279            }
1280        }
1281
1282        let output = compile_stack_spec(stack_of("OreRound", entity), None)
1283            .expect("rust stack generation should succeed");
1284        let types = &output.types_rs;
1285
1286        assert!(
1287            !types.contains("Vec<serde_json::Value>"),
1288            "scalar arrays should not fall back to untyped values:\n{types}"
1289        );
1290
1291        // u64 arrives on the wire as decimal strings (canonical numeric rule),
1292        // so the typed vector needs the string-or-number vector deserializer.
1293        assert!(
1294            types.contains(
1295                "#[serde(default, deserialize_with = \"serde_utils::deserialize_option_option_vec_u64\")]\n    pub deployed_per_square: Option<Option<Vec<u64>>>,"
1296            ),
1297            "expected a typed u64 vector with its deserializer:\n{types}"
1298        );
1299        // Small unsigned ints widen to u64, matching the scalar policy in
1300        // `normalized_integer_kind` (only u64/i64/u32/i32 have deserializers).
1301        assert!(
1302            types.contains(
1303                "#[serde(default, deserialize_with = \"serde_utils::deserialize_option_option_vec_u64\")]\n    pub resolved_seed: Option<Option<Vec<u64>>>,"
1304            ),
1305            "expected u8 arrays to widen to Vec<u64>:\n{types}"
1306        );
1307
1308        // Non-integer scalar arrays keep their element type and need no
1309        // custom deserializer.
1310        assert!(types.contains(
1311            "#[serde(default)]\n    pub deployed_per_square_ui: Option<Option<Vec<f64>>>,"
1312        ));
1313        assert!(types.contains("#[serde(default)]\n    pub flags: Option<Option<Vec<bool>>>,"));
1314        assert!(types.contains("#[serde(default)]\n    pub labels: Option<Option<Vec<String>>>,"));
1315
1316        // `BaseType::Binary` is untouched by the integer-array branch.
1317        assert!(
1318            types.contains("#[serde(default)]\n    pub payload: Option<Option<Vec<u8>>>,"),
1319            "binary fields must keep Vec<u8>:\n{types}"
1320        );
1321    }
1322
1323    /// Builtin resolver outputs are typed against generated structs, matching
1324    /// TypeScript's `oreMetadata: TokenMetadata | null` /
1325    /// `expiresAtSlotHash: SlotHashBytes | null`. `expires_at_slot_hash` only
1326    /// names the resolver output type in `field_mappings` (the section keeps
1327    /// the user's declared `ResolvedSlotHash`), which is the TypeScript
1328    /// "effective field info" override.
1329    #[test]
1330    fn rust_generator_types_builtin_resolver_fields() {
1331        let mut entity = minimal_entity("OreRound");
1332        let mut ore_metadata = FieldTypeInfo::new(
1333            "ore_metadata".to_string(),
1334            "Option<TokenMetadata>".to_string(),
1335        );
1336        ore_metadata.base_type = BaseType::Object;
1337        ore_metadata.is_optional = true;
1338        ore_metadata.inner_type = Some("TokenMetadata".to_string());
1339
1340        let mut expires_at_slot_hash = FieldTypeInfo::new(
1341            "expires_at_slot_hash".to_string(),
1342            "Option<ResolvedSlotHash>".to_string(),
1343        );
1344        expires_at_slot_hash.base_type = BaseType::Object;
1345        expires_at_slot_hash.is_optional = true;
1346        expires_at_slot_hash.inner_type = Some("ResolvedSlotHash".to_string());
1347
1348        // `KeccakRngValue` is a registered resolver output type, but it is a
1349        // u64 that the wire spells as a decimal string; Rust decodes it.
1350        let mut rng = FieldTypeInfo::new("rng".to_string(), "Option<u64>".to_string());
1351        rng.is_optional = true;
1352        rng.inner_type = Some("KeccakRngValue".to_string());
1353        rng.integer_kind = Some(IntegerKind::U64);
1354
1355        entity.sections.push(EntitySection {
1356            name: "results".to_string(),
1357            fields: vec![expires_at_slot_hash.clone(), rng],
1358            is_nested_struct: false,
1359            parent_field: None,
1360        });
1361        entity.sections.push(EntitySection {
1362            name: "root".to_string(),
1363            fields: vec![ore_metadata],
1364            is_nested_struct: false,
1365            parent_field: None,
1366        });
1367
1368        let mut slot_hash_mapping = expires_at_slot_hash;
1369        slot_hash_mapping.base_type = BaseType::Any;
1370        slot_hash_mapping.inner_type = Some("SlotHashBytes".to_string());
1371        entity.field_mappings.insert(
1372            "results.expires_at_slot_hash".to_string(),
1373            slot_hash_mapping,
1374        );
1375
1376        let output = compile_stack_spec(stack_of("OreRound", entity), None)
1377            .expect("rust stack generation should succeed");
1378        let types = &output.types_rs;
1379
1380        assert!(
1381            types.contains("pub ore_metadata: Option<Option<TokenMetadata>>,"),
1382            "expected a typed TokenMetadata field:\n{types}"
1383        );
1384        assert!(
1385            types.contains("pub expires_at_slot_hash: Option<Option<SlotHashBytes>>,"),
1386            "expected the field_mappings override to type the slot hash:\n{types}"
1387        );
1388        assert!(!types.contains("pub ore_metadata: Option<Option<serde_json::Value>>,"));
1389        assert!(!types.contains("pub expires_at_slot_hash: Option<Option<serde_json::Value>>,"));
1390
1391        // The structs themselves are emitted once, with the snake_case wire keys.
1392        assert_eq!(types.matches("pub struct TokenMetadata {").count(), 1);
1393        assert_eq!(types.matches("pub struct SlotHashBytes {").count(), 1);
1394        assert!(types.contains("    pub logo_uri: Option<String>,"));
1395        assert!(types.contains("    pub bytes: Vec<u8>,"));
1396
1397        // `KeccakRngValue` stays a real u64 rather than degrading to String.
1398        assert!(
1399            types.contains(
1400                "#[serde(default, deserialize_with = \"serde_utils::deserialize_option_option_u64\")]\n    pub rng: Option<Option<u64>>,"
1401            ),
1402            "KeccakRngValue fields must stay integers:\n{types}"
1403        );
1404        assert!(!types.contains("pub struct KeccakRngValue"));
1405    }
1406
1407    /// Unused builtin resolver structs are not emitted.
1408    #[test]
1409    fn rust_generator_omits_unused_builtin_resolver_structs() {
1410        let output = compile_stack_spec(stack_of("OreTreasury", capture_entity()), None)
1411            .expect("rust stack generation should succeed");
1412
1413        assert!(!output.types_rs.contains("pub struct TokenMetadata"));
1414        assert!(!output.types_rs.contains("pub struct SlotHashBytes"));
1415    }
1416
1417    #[test]
1418    fn generated_manifest_uses_published_arete_sdk_package() {
1419        let manifest = generate_stack_cargo_toml(&RustStackConfig::default());
1420
1421        assert!(manifest.contains("arete-sdk = { package = \"arete-a4-sdk\", version = \"0.4\" }"));
1422    }
1423
1424    fn resolved_field_of(name: &str, field_type: &str, base_type: BaseType) -> ResolvedField {
1425        ResolvedField {
1426            field_name: name.to_string(),
1427            raw_name: Some(name.to_string()),
1428            canonical_name: None,
1429            field_type: field_type.to_string(),
1430            base_type,
1431            integer_kind: IntegerKind::from_rust_type(field_type),
1432            is_optional: false,
1433            is_array: false,
1434        }
1435    }
1436
1437    /// A root-section field backed by a resolved struct. Mirror of the Python
1438    /// generator's `snapshot_field` fixture.
1439    fn snapshot_field(
1440        field_name: &str,
1441        type_name: &str,
1442        is_account: bool,
1443        is_event: bool,
1444    ) -> FieldTypeInfo {
1445        FieldTypeInfo {
1446            field_name: field_name.to_string(),
1447            raw_name: Some(field_name.to_string()),
1448            canonical_name: None,
1449            rust_type_name: "Option<serde_json::Value>".to_string(),
1450            base_type: BaseType::Object,
1451            integer_kind: None,
1452            is_optional: true,
1453            is_array: false,
1454            inner_type: Some("Value".to_string()),
1455            source_path: None,
1456            resolved_type: Some(ResolvedStructType {
1457                type_name: type_name.to_string(),
1458                fields: vec![
1459                    resolved_field_of("motherlode", "u64", BaseType::Integer),
1460                    resolved_field_of("owner", "publicKey", BaseType::Pubkey),
1461                ],
1462                is_instruction: false,
1463                is_account,
1464                is_event,
1465                is_enum: false,
1466                enum_variants: vec![],
1467            }),
1468            emit: true,
1469        }
1470    }
1471
1472    /// The handler mapping that feeds a field via `#[capture]`.
1473    fn capture_handler(target_path: &str) -> SerializableHandlerSpec {
1474        SerializableHandlerSpec {
1475            source: SourceSpec::Source {
1476                program_id: None,
1477                discriminator: None,
1478                type_name: "Treasury".to_string(),
1479                serialization: None,
1480                is_account: true,
1481            },
1482            key_resolution: KeyResolutionStrategy::Embedded {
1483                primary_field: FieldPath::new(&["id", "address"]),
1484            },
1485            mappings: vec![SerializableFieldMapping {
1486                target_path: target_path.to_string(),
1487                source: MappingSource::AsCapture {
1488                    field_transforms: BTreeMap::new(),
1489                },
1490                transform: None,
1491                population: PopulationStrategy::LastWrite,
1492                condition: None,
1493                when: None,
1494                stop: None,
1495                emit: true,
1496            }],
1497            conditions: vec![],
1498            emit: true,
1499        }
1500    }
1501
1502    fn stack_of(name: &str, entity: SerializableStreamSpec) -> SerializableStackSpec {
1503        SerializableStackSpec {
1504            ast_version: CURRENT_AST_VERSION.to_string(),
1505            stack_name: name.to_string(),
1506            program_ids: vec![],
1507            idls: vec![],
1508            program_specs: vec![],
1509            entities: vec![entity],
1510            pdas: BTreeMap::new(),
1511            instructions: vec![],
1512            content_hash: None,
1513        }
1514    }
1515
1516    fn capture_entity() -> SerializableStreamSpec {
1517        let mut entity = minimal_entity("OreTreasury");
1518        entity.handlers.push(capture_handler("treasury_snapshot"));
1519        entity.sections.push(EntitySection {
1520            name: "root".to_string(),
1521            fields: vec![
1522                snapshot_field("treasury_snapshot", "Treasury", true, false),
1523                // Same struct kind, but no AsCapture mapping: stays unwrapped.
1524                snapshot_field("plain_account", "Vault", true, false),
1525                snapshot_field("deposit_event", "DepositEvent", false, true),
1526            ],
1527            is_nested_struct: false,
1528            parent_field: None,
1529        });
1530        entity
1531    }
1532
1533    /// `#[capture]`-fed account fields and event fields arrive wrapped on the
1534    /// wire (`{timestamp, account_address, data: {...}, slot?, signature?}`).
1535    /// Emitting them as untyped `serde_json::Value` loses the typing TS and
1536    /// Python give; the envelope itself stays exposed because the provenance
1537    /// is unrecoverable elsewhere.
1538    #[test]
1539    fn rust_generator_wraps_capture_and_event_fields() {
1540        let output = compile_stack_spec(stack_of("OreTreasury", capture_entity()), None)
1541            .expect("rust stack generation should succeed");
1542        let types = &output.types_rs;
1543
1544        // Both envelopes are emitted once, with the full provenance surface.
1545        assert!(types.contains("pub struct EventWrapper<T> {"));
1546        assert!(types.contains("pub struct CaptureWrapper<T> {"));
1547        assert!(types.contains("    pub account_address: String,"));
1548        assert!(types.contains("    pub data: T,"));
1549        assert!(types.contains("    pub slot: Option<u64>,"));
1550        assert!(types.contains("    pub signature: Option<String>,"));
1551        assert_eq!(types.matches("pub struct CaptureWrapper<T>").count(), 1);
1552
1553        // Capture-fed account field: typed envelope, not an untyped blob.
1554        assert!(
1555            types.contains("pub treasury_snapshot: Option<Option<CaptureWrapper<Treasury>>>,"),
1556            "expected a typed capture envelope, got:\n{types}"
1557        );
1558        assert!(!types.contains("pub treasury_snapshot: Option<Option<serde_json::Value>>,"));
1559
1560        // Event field: EventWrapper envelope.
1561        assert!(types.contains("pub deposit_event: Option<Option<EventWrapper<DepositEvent>>>,"));
1562
1563        // Unmapped account field keeps the bare-struct shape.
1564        assert!(types.contains("pub plain_account: Option<Option<Vault>>,"));
1565        assert!(!types.contains("CaptureWrapper<Vault>"));
1566
1567        // The inner structs are still emitted so the envelopes resolve.
1568        assert!(types.contains("pub struct Treasury {"));
1569        assert!(types.contains("pub struct Vault {"));
1570        assert!(types.contains("pub struct DepositEvent {"));
1571    }
1572
1573    /// The single-entity path (`compile_serializable_spec`) emits the same
1574    /// envelopes as the stack path.
1575    #[test]
1576    fn rust_generator_wraps_capture_fields_in_single_entity_mode() {
1577        let output = compile_serializable_spec(capture_entity(), "OreTreasury".to_string(), None)
1578            .expect("rust sdk generation should succeed");
1579        let types = &output.types_rs;
1580
1581        assert!(types.contains("pub struct CaptureWrapper<T> {"));
1582        assert!(types.contains("pub struct EventWrapper<T> {"));
1583        assert!(types.contains("pub treasury_snapshot: Option<Option<CaptureWrapper<Treasury>>>,"));
1584        assert!(types.contains("pub deposit_event: Option<Option<EventWrapper<DepositEvent>>>,"));
1585        assert!(types.contains("pub plain_account: Option<Option<Vault>>,"));
1586    }
1587
1588    /// `CaptureWrapper` is reserved in the resolved-type name map, so an IDL
1589    /// struct actually named `CaptureWrapper` is renamed instead of shadowing
1590    /// the envelope.
1591    #[test]
1592    fn rust_generator_reserves_wrapper_type_names() {
1593        let mut entity = minimal_entity("OreTreasury");
1594        entity.sections.push(EntitySection {
1595            name: "root".to_string(),
1596            fields: vec![
1597                snapshot_field("wrapped", "CaptureWrapper", true, false),
1598                snapshot_field("evented", "EventWrapper", true, false),
1599            ],
1600            is_nested_struct: false,
1601            parent_field: None,
1602        });
1603
1604        let output = compile_stack_spec(stack_of("OreTreasury", entity), None)
1605            .expect("rust stack generation should succeed");
1606        let types = &output.types_rs;
1607
1608        assert!(types.contains("pub struct CaptureWrapperAccount {"));
1609        assert!(types.contains("pub struct EventWrapperAccount {"));
1610        assert!(types.contains("pub wrapped: Option<Option<CaptureWrapperAccount>>,"));
1611        assert!(types.contains("pub evented: Option<Option<EventWrapperAccount>>,"));
1612        assert_eq!(types.matches("pub struct CaptureWrapper<T>").count(), 1);
1613    }
1614
1615    const TEST_PROGRAM_ID: &str = "Prog111111111111111111111111111111111111111";
1616
1617    fn minimal_entity(name: &str) -> SerializableStreamSpec {
1618        SerializableStreamSpec {
1619            ast_version: CURRENT_AST_VERSION.to_string(),
1620            state_name: name.to_string(),
1621            program_id: None,
1622            idl: None,
1623            identity: identity_spec(),
1624            handlers: vec![],
1625            sections: vec![EntitySection {
1626                name: "id".to_string(),
1627                fields: vec![FieldTypeInfo::new(
1628                    "address".to_string(),
1629                    "String".to_string(),
1630                )],
1631                is_nested_struct: false,
1632                parent_field: None,
1633            }],
1634            field_mappings: BTreeMap::new(),
1635            resolver_hooks: vec![],
1636            instruction_hooks: vec![],
1637            resolver_specs: vec![],
1638            computed_fields: vec![],
1639            computed_field_specs: vec![],
1640            content_hash: None,
1641            views: vec![],
1642        }
1643    }
1644
1645    fn test_idl() -> IdlSnapshot {
1646        IdlSnapshot {
1647            name: "demo".to_string(),
1648            program_id: Some(TEST_PROGRAM_ID.to_string()),
1649            version: "0.1.0".to_string(),
1650            accounts: vec![],
1651            instructions: vec![],
1652            types: vec![],
1653            events: vec![],
1654            errors: vec![IdlErrorSnapshot {
1655                code: 6000,
1656                name: "SlippageExceeded".to_string(),
1657                msg: Some("Slippage exceeded".to_string()),
1658            }],
1659            discriminant_size: 8,
1660        }
1661    }
1662
1663    fn instruction_account(name: &str, resolution: AccountResolution) -> InstructionAccountDef {
1664        InstructionAccountDef {
1665            name: name.to_string(),
1666            is_signer: matches!(resolution, AccountResolution::Signer),
1667            is_writable: true,
1668            resolution,
1669            is_optional: false,
1670            docs: vec![],
1671        }
1672    }
1673
1674    fn instruction_arg(name: &str, arg_type: &str) -> InstructionArgDef {
1675        InstructionArgDef {
1676            name: name.to_string(),
1677            arg_type: arg_type.to_string(),
1678            docs: vec![],
1679            amount_hint: None,
1680        }
1681    }
1682
1683    fn programs_stack_spec() -> SerializableStackSpec {
1684        let mut demo_pdas = BTreeMap::new();
1685        demo_pdas.insert(
1686            "counter".to_string(),
1687            PdaDefinition {
1688                name: "counter".to_string(),
1689                seeds: vec![
1690                    PdaSeedDef::Literal {
1691                        value: "counter".to_string(),
1692                    },
1693                    PdaSeedDef::AccountRef {
1694                        account_name: "authority".to_string(),
1695                    },
1696                ],
1697                program_id: None,
1698            },
1699        );
1700        let mut pdas = BTreeMap::new();
1701        pdas.insert("demo".to_string(), demo_pdas);
1702
1703        SerializableStackSpec {
1704            ast_version: CURRENT_AST_VERSION.to_string(),
1705            stack_name: "Demo".to_string(),
1706            program_ids: vec![TEST_PROGRAM_ID.to_string()],
1707            idls: vec![test_idl()],
1708            program_specs: vec![],
1709            entities: vec![minimal_entity("DemoThing")],
1710            pdas,
1711            instructions: vec![InstructionDef {
1712                name: "doThing".to_string(),
1713                discriminator: vec![12, 34],
1714                discriminator_size: 2,
1715                accounts: vec![
1716                    instruction_account("signer", AccountResolution::Signer),
1717                    instruction_account("authority", AccountResolution::UserProvided),
1718                    instruction_account(
1719                        "counter",
1720                        AccountResolution::PdaRef {
1721                            pda_name: "counter".to_string(),
1722                        },
1723                    ),
1724                    instruction_account(
1725                        "systemProgram",
1726                        AccountResolution::Known {
1727                            address: "11111111111111111111111111111111".to_string(),
1728                        },
1729                    ),
1730                ],
1731                args: vec![
1732                    instruction_arg("roundId", "u64"),
1733                    instruction_arg("admin", "solana_pubkey::Pubkey"),
1734                    instruction_arg("tip", "Option<u64>"),
1735                ],
1736                errors: vec![],
1737                program_id: Some(TEST_PROGRAM_ID.to_string()),
1738                docs: vec!["Does the thing.".to_string()],
1739            }],
1740            content_hash: None,
1741        }
1742    }
1743
1744    #[test]
1745    fn rust_generator_emits_program_sdk_module() {
1746        let output = compile_stack_spec(programs_stack_spec(), None)
1747            .expect("rust stack generation should succeed");
1748        let programs = output
1749            .programs_rs
1750            .expect("programs.rs should be generated for stacks with instructions");
1751
1752        assert!(programs.contains("pub mod demo {"));
1753        assert!(programs.contains(&format!(
1754            "pub const PROGRAM_ID: &str = \"{}\";",
1755            TEST_PROGRAM_ID
1756        )));
1757
1758        // Typed params: args (with serde renames) then account overrides.
1759        assert!(programs.contains("pub struct DoThingParams {"));
1760        assert!(programs.contains("#[serde(rename = \"roundId\")]"));
1761        assert!(programs.contains("pub round_id: u64,"));
1762        assert!(programs.contains("pub admin: String,"));
1763        assert!(programs.contains("pub tip: Option<u64>,"));
1764        assert!(programs.contains("pub signer: Option<String>,"));
1765        assert!(programs.contains("pub authority: String,"));
1766        assert!(programs.contains("#[serde(skip_serializing_if = \"Option::is_none\")]"));
1767
1768        // Handler literal fragments.
1769        assert!(programs.contains("discriminator: vec![12, 34]"));
1770        assert!(programs.contains("resolution: AccountResolution::Signer,"));
1771        assert!(programs.contains(
1772            "AccountResolution::Known(\"11111111111111111111111111111111\".to_string())"
1773        ));
1774        assert!(programs.contains(
1775            "AccountResolution::Pda(PdaConfig { program_id: None, seeds: vec![PdaSeed::Literal(\"counter\".to_string()), PdaSeed::AccountRef(\"authority\".to_string())] })"
1776        ));
1777        assert!(programs.contains("ArgSchema { name: \"roundId\".to_string(), ty: ArgType::U64 }"));
1778        assert!(programs.contains("ty: ArgType::Option(Box::new(ArgType::U64))"));
1779        assert!(programs.contains("ty: ArgType::Pubkey"));
1780        assert!(programs.contains(
1781            "ErrorMetadata { code: 6000, name: \"SlippageExceeded\".to_string(), msg: \"Slippage exceeded\".to_string() }"
1782        ));
1783
1784        // PDA helper fn.
1785        assert!(programs
1786            .contains("pub fn counter(authority: &str) -> Result<(Pubkey, u8), InstructionError>"));
1787
1788        // Program accessor struct carries the runtime + typed builder.
1789        assert!(programs.contains("pub struct DemoProgram {"));
1790        assert!(programs.contains("builder: arete_sdk::ProgramBuilder,"));
1791        assert!(
1792            programs.contains("pub fn from_builder(builder: arete_sdk::ProgramBuilder) -> Self")
1793        );
1794        assert!(programs.contains(
1795            "pub fn do_thing(params: DoThingParams) -> Result<BuiltInstruction, InstructionError>"
1796        ));
1797        assert!(programs.contains("pub fn do_thing_handler() -> InstructionHandler"));
1798
1799        // No program spec recorded: the read layer is omitted with a doc note.
1800        assert!(programs.contains(
1801            "/// Program read layer omitted: no program specification was recorded for this program."
1802        ));
1803        assert!(!programs.contains("pub const PROGRAM_SPEC_HASH"));
1804        assert!(!programs.contains("pub fn read_descriptor"));
1805
1806        // Stack wiring.
1807        assert!(output
1808            .entity_rs
1809            .contains("type Programs = DemoStackPrograms;"));
1810        assert!(output
1811            .entity_rs
1812            .contains("pub demo: crate::programs::demo::DemoProgram,"));
1813        assert!(output
1814            .entity_rs
1815            .contains("demo: crate::programs::demo::DemoProgram::from_builder(builder),"));
1816        assert!(output
1817            .entity_rs
1818            .contains("impl arete_sdk::Programs for DemoStackPrograms"));
1819        assert!(output.lib_rs.contains("pub mod programs;"));
1820        assert!(output.lib_rs.contains("DemoStackPrograms"));
1821    }
1822
1823    #[test]
1824    fn rust_generator_without_instructions_binds_unit_programs() {
1825        let mut spec = programs_stack_spec();
1826        spec.instructions.clear();
1827
1828        let output = compile_stack_spec(spec, None).expect("rust stack generation should succeed");
1829
1830        assert!(output.programs_rs.is_none());
1831        assert!(output.entity_rs.contains("type Programs = ();"));
1832        assert!(!output.lib_rs.contains("pub mod programs;"));
1833        assert!(!output.entity_rs.contains("StackPrograms"));
1834    }
1835
1836    #[test]
1837    fn rust_generator_notes_skipped_instructions() {
1838        let mut spec = programs_stack_spec();
1839        spec.instructions.push(InstructionDef {
1840            name: "badThing".to_string(),
1841            discriminator: vec![9],
1842            discriminator_size: 1,
1843            accounts: vec![],
1844            args: vec![instruction_arg("payload", "MysteryType")],
1845            errors: vec![],
1846            program_id: Some(TEST_PROGRAM_ID.to_string()),
1847            docs: vec![],
1848        });
1849
1850        let output = compile_stack_spec(spec, None).expect("rust stack generation should succeed");
1851        let programs = output.programs_rs.expect("programs.rs should be generated");
1852
1853        assert!(programs.contains("/// Skipped instructions (unsupported by instruction codegen):"));
1854        assert!(
1855            programs.contains("/// - `badThing`: arg 'payload' has unsupported type 'MysteryType'")
1856        );
1857        assert!(!programs.contains("BadThingParams"));
1858        // The supported instruction is still emitted.
1859        assert!(programs.contains("pub struct DoThingParams {"));
1860    }
1861
1862    #[test]
1863    fn rust_generator_emits_program_read_layer() {
1864        // Build a stack spec through the real program-spec pipeline so the
1865        // recorded ProgramSpecV1 hashes exactly like production specs.
1866        let idl_json = format!(
1867            r#"{{
1868              "address": "{TEST_PROGRAM_ID}",
1869              "version": "0.1.0",
1870              "name": "demo",
1871              "instructions": [
1872                {{
1873                  "name": "doThing",
1874                  "accounts": [{{ "name": "payer", "isMut": true, "isSigner": true }}],
1875                  "args": [{{ "name": "amount", "type": "u64" }}],
1876                  "discriminant": {{ "type": "u8", "value": 1 }}
1877                }}
1878              ],
1879              "accounts": [
1880                {{
1881                  "name": "Counter",
1882                  "type": {{
1883                    "kind": "struct",
1884                    "fields": [{{ "name": "count", "type": "u64" }}]
1885                  }}
1886                }}
1887              ],
1888              "types": [],
1889              "events": [],
1890              "errors": []
1891            }}"#
1892        );
1893        let mut spec = crate::program_sdk::build_program_only_stack_spec_from_idl_bytes(
1894            idl_json.as_bytes(),
1895            None,
1896            "Demo",
1897        )
1898        .expect("program-only stack spec should build");
1899        let expected_spec_hash = spec.program_specs[0].hash().unwrap().to_string();
1900        let expected_release_hash = spec.program_specs[0]
1901            .oss_release_hash()
1902            .unwrap()
1903            .to_string();
1904
1905        // One entity whose raw account struct (`Counter`) is emitted in types.rs.
1906        let mut entity = minimal_entity("DemoThing");
1907        entity.sections.push(EntitySection {
1908            name: "state".to_string(),
1909            fields: vec![FieldTypeInfo {
1910                field_name: "counter".to_string(),
1911                raw_name: Some("counter".to_string()),
1912                canonical_name: Some("counter".to_string()),
1913                rust_type_name: "Option<serde_json::Value>".to_string(),
1914                base_type: BaseType::Object,
1915                integer_kind: None,
1916                is_optional: false,
1917                is_array: false,
1918                inner_type: Some("Value".to_string()),
1919                source_path: None,
1920                resolved_type: Some(ResolvedStructType {
1921                    type_name: "Counter".to_string(),
1922                    fields: vec![],
1923                    is_instruction: false,
1924                    is_account: true,
1925                    is_event: false,
1926                    is_enum: false,
1927                    enum_variants: vec![],
1928                }),
1929                emit: true,
1930            }],
1931            is_nested_struct: false,
1932            parent_field: None,
1933        });
1934        spec.entities.push(entity);
1935
1936        let output = compile_stack_spec(spec, None).expect("rust stack generation should succeed");
1937        let programs = output.programs_rs.expect("programs.rs should be generated");
1938
1939        // Release identity consts + descriptor.
1940        assert!(programs.contains(&format!(
1941            "pub const PROGRAM_SPEC_HASH: &str = \"{expected_spec_hash}\";"
1942        )));
1943        assert!(programs.contains(&format!(
1944            "pub const PROGRAM_RELEASE_HASH: &str = \"{expected_release_hash}\";"
1945        )));
1946        assert!(programs.contains("pub fn read_descriptor() -> arete_sdk::ProgramReadDescriptor"));
1947        assert!(programs.contains("arete_sdk::ProgramReadDescriptor::LocalHttp"));
1948        assert!(!programs.contains("Program read layer omitted"));
1949
1950        // Typed account reader for the emitted `Counter` struct.
1951        assert!(output.types_rs.contains("pub struct Counter"));
1952        assert!(programs.contains(
1953            "pub fn counter_accounts(&self) -> Result<arete_sdk::AccountReader<crate::types::Counter>, arete_sdk::AreteError>"
1954        ));
1955        assert!(programs.contains("self.builder.account_transport(\"demo\", &read_descriptor())?"));
1956        assert!(programs.contains("arete_sdk::AccountReader::new(\n                \"Counter\","));
1957    }
1958
1959    #[test]
1960    fn rust_generator_emits_stack_http_url_override() {
1961        let output = compile_stack_spec(programs_stack_spec(), None)
1962            .expect("rust stack generation should succeed");
1963        assert!(!output.entity_rs.contains("fn http_url"));
1964
1965        let config = RustStackConfig {
1966            http_url: Some("https://demo.stack.example".to_string()),
1967            ..Default::default()
1968        };
1969        let output = compile_stack_spec(programs_stack_spec(), Some(config))
1970            .expect("rust stack generation should succeed");
1971        assert!(output.entity_rs.contains(
1972            "fn http_url() -> &'static str {\n        \"https://demo.stack.example\"\n    }"
1973        ));
1974    }
1975
1976    #[test]
1977    fn rust_generator_wires_extension_modules_after_generated_decls() {
1978        let config = RustStackConfig {
1979            module_mode: true,
1980            extension_modules: vec!["devex".to_string(), "extensions".to_string()],
1981            extension_entry: Some("extensions".to_string()),
1982            ..Default::default()
1983        };
1984        let output = compile_stack_spec(programs_stack_spec(), Some(config))
1985            .expect("rust stack generation should succeed");
1986        let mod_rs = output.mod_rs();
1987
1988        assert!(mod_rs.contains(
1989            "// Hand-authored devex extensions (staged from extensions.json; not generated)."
1990        ));
1991        let sdk_reexport = mod_rs.find("pub use arete_sdk::").expect("sdk re-export");
1992        let devex = mod_rs.find("pub mod devex;").expect("devex module decl");
1993        let entry = mod_rs
1994            .find("pub mod extensions;")
1995            .expect("entry module decl");
1996        let entry_reexport = mod_rs
1997            .find("pub use extensions::*;")
1998            .expect("entry glob re-export");
1999        assert!(sdk_reexport < devex);
2000        assert!(devex < entry);
2001        assert!(entry < entry_reexport);
2002        assert!(!mod_rs.contains("pub use devex::*;"));
2003    }
2004
2005    #[test]
2006    fn rust_generator_omits_extension_wiring_without_entry() {
2007        let output = compile_stack_spec(programs_stack_spec(), None)
2008            .expect("rust stack generation should succeed");
2009
2010        assert!(!output.mod_rs().contains("Hand-authored devex extensions"));
2011        assert!(!output.mod_rs().contains("pub mod extensions;"));
2012    }
2013
2014    #[test]
2015    fn rust_generator_rejects_extension_module_collisions() {
2016        for reserved in ["entity", "types", "programs"] {
2017            let config = RustStackConfig {
2018                extension_modules: vec![reserved.to_string(), "extensions".to_string()],
2019                extension_entry: Some("extensions".to_string()),
2020                ..Default::default()
2021            };
2022            let error = compile_stack_spec(programs_stack_spec(), Some(config))
2023                .expect_err("collision with a generated module must fail");
2024            assert!(
2025                error.contains(&format!("'{reserved}.rs'")),
2026                "collision error should name the file: {error}"
2027            );
2028        }
2029
2030        let duplicate = RustStackConfig {
2031            extension_modules: vec![
2032                "devex".to_string(),
2033                "devex".to_string(),
2034                "extensions".to_string(),
2035            ],
2036            extension_entry: Some("extensions".to_string()),
2037            ..Default::default()
2038        };
2039        assert!(compile_stack_spec(programs_stack_spec(), Some(duplicate)).is_err());
2040
2041        let entry_not_last = RustStackConfig {
2042            extension_modules: vec!["extensions".to_string(), "devex".to_string()],
2043            extension_entry: Some("extensions".to_string()),
2044            ..Default::default()
2045        };
2046        assert!(compile_stack_spec(programs_stack_spec(), Some(entry_not_last)).is_err());
2047    }
2048
2049    /// Regeneration helper for the checked-in ore example. Run with:
2050    /// `cargo test -p arete-interpreter regenerate_ore_example -- --ignored`
2051    ///
2052    /// Rewrites `examples/ore-rust/src/generated/ore/{mod,types,entity,programs}.rs`
2053    /// from `stacks/ore/.arete/OreStream.stack.json`.
2054    ///
2055    /// Extension wiring reuses the `extensions.json` staged in the output
2056    /// directory (files sorted, entry last, stems via [`rust_module_name`]) โ€”
2057    /// a faithful replica of the CLI's output-dir manifest resolution step,
2058    /// which lives in `a4-cli` and cannot be called from this crate. Staged
2059    /// extension files are preserved verbatim, so a second run is a byte-stable
2060    /// fixed point.
2061    #[test]
2062    #[ignore = "writes into examples/ore-rust; run explicitly to regenerate"]
2063    fn regenerate_ore_example() {
2064        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2065            .parent()
2066            .expect("interpreter crate lives in the repo root")
2067            .to_path_buf();
2068        let spec_json =
2069            std::fs::read_to_string(repo_root.join("stacks/ore/.arete/OreStream.stack.json"))
2070                .expect("ore stack spec should exist");
2071        let spec = crate::versioned::load_stack_spec(&spec_json)
2072            .expect("ore stack spec should deserialize");
2073
2074        let out_dir = repo_root.join("examples/ore-rust/src/generated/ore");
2075        let (extension_modules, extension_entry) =
2076            match std::fs::read_to_string(out_dir.join("extensions.json")) {
2077                Ok(manifest_json) => {
2078                    let manifest: serde_json::Value = serde_json::from_str(&manifest_json)
2079                        .expect("staged extensions.json should parse");
2080                    let language = manifest["language"].as_str();
2081                    assert!(
2082                        language.is_none() || language == Some("rust"),
2083                        "staged ore extensions must be a Rust bundle"
2084                    );
2085                    let entry_stem = rust_module_name(
2086                        manifest["entry"]
2087                            .as_str()
2088                            .and_then(|entry| entry.strip_suffix(".rs"))
2089                            .expect("extensions entry should be a .rs file"),
2090                    );
2091                    let mut stems: Vec<String> = manifest["files"]
2092                        .as_array()
2093                        .expect("extensions files should be an array")
2094                        .iter()
2095                        .map(|file| {
2096                            rust_module_name(
2097                                file.as_str()
2098                                    .and_then(|file| file.strip_suffix(".rs"))
2099                                    .expect("extension files should be .rs files"),
2100                            )
2101                        })
2102                        .filter(|stem| stem != &entry_stem)
2103                        .collect();
2104                    stems.sort();
2105                    stems.dedup();
2106                    stems.push(entry_stem.clone());
2107                    (stems, Some(entry_stem))
2108                }
2109                Err(_) => (Vec::new(), None),
2110            };
2111
2112        let config = RustStackConfig {
2113            crate_name: "ore-stack".to_string(),
2114            sdk_version: "0.4".to_string(),
2115            module_mode: true,
2116            url: Some("wss://ore.stack.arete.run".to_string()),
2117            http_url: Some("https://ore.stack.arete.run".to_string()),
2118            extension_modules,
2119            extension_entry,
2120        };
2121        let output =
2122            compile_stack_spec(spec, Some(config)).expect("ore stack should compile to Rust");
2123
2124        std::fs::write(out_dir.join("mod.rs"), output.mod_rs()).unwrap();
2125        std::fs::write(out_dir.join("types.rs"), &output.types_rs).unwrap();
2126        std::fs::write(out_dir.join("entity.rs"), &output.entity_rs).unwrap();
2127        std::fs::write(
2128            out_dir.join("programs.rs"),
2129            output.programs_rs.as_deref().expect("ore has instructions"),
2130        )
2131        .unwrap();
2132    }
2133}
2134
2135// ============================================================================
2136// Stack-level compilation (multi-entity)
2137// ============================================================================
2138
2139#[derive(Debug, Clone)]
2140pub struct RustStackConfig {
2141    pub crate_name: String,
2142    pub sdk_version: String,
2143    pub module_mode: bool,
2144    pub url: Option<String>,
2145    /// HTTP base URL for the stack (account reads / queries / chain reads).
2146    /// When set and non-empty, the generated Stack impl overrides
2147    /// `Stack::http_url`; otherwise the runtime derives the HTTP endpoint
2148    /// from the WebSocket URL.
2149    pub http_url: Option<String>,
2150    /// Module stems of hand-authored devex extension files staged next to the
2151    /// generated output (one `pub mod <stem>;` each, in order, entry last).
2152    /// Stems are derived from the staged file names via [`rust_module_name`].
2153    pub extension_modules: Vec<String>,
2154    /// Module stem of the extension entry file. When set, the generated
2155    /// `mod.rs`/`lib.rs` re-exports the entry at the stack module root
2156    /// (`pub use <entry>::*;`) so extension traits come into scope with the
2157    /// stack's own glob import.
2158    pub extension_entry: Option<String>,
2159}
2160
2161#[derive(Debug, Clone, Default)]
2162pub struct RustCompositionConfig {
2163    pub stack: RustStackConfig,
2164    pub live_urls: BTreeMap<String, String>,
2165}
2166
2167#[derive(Debug, Clone)]
2168pub struct RustAliasedStackOutput {
2169    pub alias: String,
2170    pub module_name: String,
2171    pub output: RustOutput,
2172}
2173
2174#[derive(Debug, Clone)]
2175pub struct RustCompositionOutput {
2176    pub name: String,
2177    pub cargo_toml: String,
2178    pub lib_rs: String,
2179    pub live_stacks: Vec<RustAliasedStackOutput>,
2180}
2181
2182impl Default for RustStackConfig {
2183    fn default() -> Self {
2184        Self {
2185            crate_name: "generated-stack".to_string(),
2186            sdk_version: "0.4".to_string(),
2187            module_mode: false,
2188            url: None,
2189            http_url: None,
2190            extension_modules: Vec::new(),
2191            extension_entry: None,
2192        }
2193    }
2194}
2195
2196/// Compile a full SerializableStackSpec (multi-entity) into unified Rust output.
2197///
2198/// Generates types.rs with ALL entity structs, entity.rs with a single Stack impl
2199/// and per-entity EntityViews, and mod.rs/lib.rs re-exporting everything.
2200pub fn compile_stack_spec(
2201    stack_spec: SerializableStackSpec,
2202    config: Option<RustStackConfig>,
2203) -> Result<RustOutput, String> {
2204    compile_stack_spec_with_view_selection(stack_spec, config, false)
2205}
2206
2207fn compile_stack_spec_with_view_selection(
2208    stack_spec: SerializableStackSpec,
2209    config: Option<RustStackConfig>,
2210    exact_views: bool,
2211) -> Result<RustOutput, String> {
2212    let config = config.unwrap_or_default();
2213    let stack_name = &stack_spec.stack_name;
2214    let stack_kebab = to_kebab_case(stack_name);
2215
2216    let mut entity_names: Vec<String> = Vec::new();
2217    let mut entity_specs: Vec<SerializableStreamSpec> = Vec::new();
2218
2219    for mut spec in stack_spec.entities {
2220        if spec.idl.is_none() {
2221            spec.idl = stack_spec.idls.first().cloned();
2222        }
2223        entity_names.push(spec.state_name.clone());
2224        entity_specs.push(spec);
2225    }
2226
2227    let view_entity_names = entity_specs
2228        .iter()
2229        .zip(&entity_names)
2230        .filter(|(spec, _)| !exact_views || !spec.views.is_empty())
2231        .map(|(_, name)| name.clone())
2232        .collect::<Vec<_>>();
2233
2234    let (types_rs, account_structs) = generate_stack_types_rs(&entity_specs, &entity_names);
2235
2236    let programs = generate_stack_programs_rs(
2237        stack_name,
2238        &stack_spec.instructions,
2239        &stack_spec.idls,
2240        &stack_spec.pdas,
2241        &stack_spec.program_ids,
2242        &stack_spec.program_specs,
2243        &account_structs,
2244        config.module_mode,
2245    );
2246    let entity_rs = generate_stack_entity_rs(
2247        stack_name,
2248        &stack_kebab,
2249        &entity_specs,
2250        &entity_names,
2251        &config,
2252        exact_views,
2253        programs.as_ref(),
2254    );
2255    validate_extension_modules(&config, programs.is_some())?;
2256    let lib_rs = generate_stack_lib_rs(
2257        stack_name,
2258        &view_entity_names,
2259        config.module_mode,
2260        programs.is_some(),
2261        &config.extension_modules,
2262        config.extension_entry.as_deref(),
2263    );
2264    let cargo_toml = generate_stack_cargo_toml(&config);
2265
2266    Ok(RustOutput {
2267        cargo_toml,
2268        lib_rs,
2269        types_rs,
2270        entity_rs,
2271        programs_rs: programs.map(|codegen| codegen.code),
2272    })
2273}
2274
2275/// Compile a stack model whose `views` have already been projected by a
2276/// StackManifest selected-view allowlist.
2277pub fn compile_stack_spec_with_exact_views(
2278    stack_spec: SerializableStackSpec,
2279    config: Option<RustStackConfig>,
2280) -> Result<RustOutput, String> {
2281    compile_stack_spec_with_view_selection(stack_spec, config, true)
2282}
2283
2284/// Compile Rust output from an explicit StackManifest and its public dependencies.
2285pub fn compile_public_artifacts(
2286    programs: &[arete_artifacts::ProgramSpecArtifact],
2287    live_spec: &arete_artifacts::LiveSpecArtifact,
2288    manifest: &arete_artifacts::StackManifestArtifact,
2289    config: Option<RustStackConfig>,
2290) -> Result<RustOutput, String> {
2291    let stack_spec =
2292        crate::public_artifacts::stack_spec_from_artifacts(programs, live_spec, manifest)?;
2293    compile_stack_spec(stack_spec, config)
2294}
2295
2296/// Compile typed V2 public artifacts through the current single-live generator.
2297pub fn compile_public_artifacts_v2(
2298    programs: &[arete_artifacts::ProgramSpecArtifact],
2299    live_spec: &arete_artifacts::LiveSpecArtifactV2,
2300    manifest: &arete_artifacts::StackManifestArtifactV2,
2301    config: Option<RustStackConfig>,
2302) -> Result<RustOutput, String> {
2303    let stack_spec =
2304        crate::public_artifacts::stack_spec_from_artifacts_v2(programs, live_spec, manifest)?;
2305    compile_stack_spec_with_view_selection(stack_spec, config, true)
2306}
2307
2308/// Generate one namespaced Rust stack module per live alias plus a manifest
2309/// module that preserves alias boundaries instead of flattening views/adapters.
2310pub fn compile_composed_public_artifacts_v2(
2311    programs: &[arete_artifacts::ProgramSpecArtifact],
2312    live_specs: &[(String, arete_artifacts::LiveSpecArtifactV2)],
2313    manifest: &arete_artifacts::StackManifestArtifactV2,
2314    config: Option<RustCompositionConfig>,
2315) -> Result<RustCompositionOutput, String> {
2316    let composed =
2317        crate::public_artifacts::stack_specs_from_artifacts_v2(programs, live_specs, manifest)?;
2318    if composed.live_specs.is_empty() {
2319        return Err(
2320            "Rust composition generation requires at least one aliased LiveSpec".to_string(),
2321        );
2322    }
2323    let config = config.unwrap_or_default();
2324    if !config.stack.extension_modules.is_empty() || config.stack.extension_entry.is_some() {
2325        return Err(
2326            "Rust composition SDKs do not support stack extensions; extensions attach to a single-live stack module".to_string(),
2327        );
2328    }
2329    let mut live_stacks = Vec::with_capacity(composed.live_specs.len());
2330    for live in composed.live_specs {
2331        let module_name = rust_module_name(&live.alias);
2332        let mut live_config = config.stack.clone();
2333        live_config.module_mode = true;
2334        live_config.url = config.live_urls.get(&live.alias).cloned();
2335        let output =
2336            compile_stack_spec_with_view_selection(live.stack_spec, Some(live_config), true)?;
2337        live_stacks.push(RustAliasedStackOutput {
2338            alias: live.alias,
2339            module_name,
2340            output,
2341        });
2342    }
2343    let lib_rs = live_stacks
2344        .iter()
2345        .map(|live| format!("pub mod {};", live.module_name))
2346        .collect::<Vec<_>>()
2347        .join("\n");
2348    Ok(RustCompositionOutput {
2349        name: composed.name,
2350        cargo_toml: generate_stack_cargo_toml(&config.stack),
2351        lib_rs: format!("{lib_rs}\n"),
2352        live_stacks,
2353    })
2354}
2355
2356pub fn write_rust_composition_crate(
2357    output: &RustCompositionOutput,
2358    crate_dir: &std::path::Path,
2359) -> Result<(), std::io::Error> {
2360    let source = crate_dir.join("src");
2361    std::fs::create_dir_all(&source)?;
2362    std::fs::write(crate_dir.join("Cargo.toml"), &output.cargo_toml)?;
2363    std::fs::write(source.join("lib.rs"), &output.lib_rs)?;
2364    for live in &output.live_stacks {
2365        write_rust_module(&live.output, &source.join(&live.module_name))?;
2366    }
2367    Ok(())
2368}
2369
2370pub fn write_rust_composition_module(
2371    output: &RustCompositionOutput,
2372    module_dir: &std::path::Path,
2373) -> Result<(), std::io::Error> {
2374    std::fs::create_dir_all(module_dir)?;
2375    std::fs::write(module_dir.join("mod.rs"), &output.lib_rs)?;
2376    for live in &output.live_stacks {
2377        write_rust_module(&live.output, &module_dir.join(&live.module_name))?;
2378    }
2379    Ok(())
2380}
2381
2382fn generate_stack_cargo_toml(config: &RustStackConfig) -> String {
2383    format!(
2384        r#"[package]
2385name = "{}"
2386version = "0.1.0"
2387edition = "2021"
2388
2389[dependencies]
2390arete-sdk = {{ package = "arete-a4-sdk", version = "{}" }}
2391serde = {{ version = "1", features = ["derive"] }}
2392serde_json = "1"
2393"#,
2394        config.crate_name, config.sdk_version
2395    )
2396}
2397
2398/// Validate hand-authored extension module stems against the generated
2399/// module names. Entry-stem collisions are a hard error because the staged
2400/// file would shadow (or be shadowed by) a generated file.
2401fn validate_extension_modules(config: &RustStackConfig, has_programs: bool) -> Result<(), String> {
2402    if config.extension_modules.is_empty() && config.extension_entry.is_none() {
2403        return Ok(());
2404    }
2405    if config.extension_entry.is_none() {
2406        return Err("extension modules were configured without an extension entry".to_string());
2407    }
2408    let mut seen = HashSet::new();
2409    for stem in &config.extension_modules {
2410        let reserved = matches!(stem.as_str(), "entity" | "types" | "mod" | "lib")
2411            || (stem == "programs" && has_programs);
2412        if reserved {
2413            return Err(format!(
2414                "extension file '{stem}.rs' collides with the generated '{stem}' module; rename the extension file"
2415            ));
2416        }
2417        if !seen.insert(stem.as_str()) {
2418            return Err(format!(
2419                "extension file '{stem}.rs' resolves to the same module name as another staged extension file"
2420            ));
2421        }
2422    }
2423    match &config.extension_entry {
2424        Some(entry) if config.extension_modules.last() == Some(entry) => Ok(()),
2425        Some(entry) => Err(format!(
2426            "extension entry module '{entry}' must be the last configured extension module"
2427        )),
2428        None => unreachable!("checked above"),
2429    }
2430}
2431
2432fn generate_stack_lib_rs(
2433    stack_name: &str,
2434    entity_names: &[String],
2435    _module_mode: bool,
2436    has_programs: bool,
2437    extension_modules: &[String],
2438    extension_entry: Option<&str>,
2439) -> String {
2440    let entity_views_exports: Vec<String> = entity_names
2441        .iter()
2442        .map(|name| format!("{}EntityViews", name))
2443        .collect();
2444
2445    let mut all_exports = format!(
2446        "{}Stack, {}StackViews, {}",
2447        stack_name,
2448        stack_name,
2449        entity_views_exports.join(", ")
2450    );
2451    if has_programs {
2452        all_exports.push_str(&format!(", {}StackPrograms", stack_name));
2453    }
2454
2455    let programs_mod = if has_programs {
2456        "\npub mod programs;"
2457    } else {
2458        ""
2459    };
2460
2461    let mut output = format!(
2462        r#"mod entity;
2463mod types;{programs_mod}
2464
2465pub use entity::{{{all_exports}}};
2466pub use types::*;
2467
2468pub use arete_sdk::{{ConnectionState, Arete, Stack, Update, Views}};
2469"#,
2470        programs_mod = programs_mod,
2471        all_exports = all_exports
2472    );
2473
2474    if let Some(entry) = extension_entry {
2475        output.push_str(
2476            "\n// Hand-authored devex extensions (staged from extensions.json; not generated).\n",
2477        );
2478        for stem in extension_modules {
2479            output.push_str(&format!("pub mod {stem};\n"));
2480        }
2481        output.push_str(&format!("pub use {entry}::*;\n"));
2482    }
2483
2484    output
2485}
2486
2487/// Generate types.rs containing structs for ALL entities in the stack.
2488///
2489/// Also returns the map of emitted raw account structs (IDL account type name
2490/// -> emitted Rust struct name) so the program SDK generator can attach typed
2491/// account readers for accounts that actually have a generated struct.
2492fn generate_stack_types_rs(
2493    entity_specs: &[SerializableStreamSpec],
2494    entity_names: &[String],
2495) -> (String, BTreeMap<String, String>) {
2496    let mut output = String::new();
2497    output.push_str("use serde::{Deserialize, Serialize};\n");
2498    output.push_str("use arete_sdk::serde_utils;\n\n");
2499
2500    let mut generated = HashSet::new();
2501    let mut account_structs: BTreeMap<String, String> = BTreeMap::new();
2502    let mut used_builtins: BTreeSet<&'static str> = BTreeSet::new();
2503
2504    for (i, spec) in entity_specs.iter().enumerate() {
2505        let entity_name = &entity_names[i];
2506        let compiler = RustCompiler::new(spec.clone(), entity_name.clone(), RustConfig::default());
2507        let resolved_name_map = compiler.build_resolved_type_name_map();
2508        used_builtins.extend(compiler.used_builtin_resolver_types());
2509
2510        // Generate section structs (e.g., OreRoundId, OreRoundState)
2511        for section in &spec.sections {
2512            if !RustCompiler::is_root_section(&section.name) {
2513                let struct_name = format!("{}{}", entity_name, to_pascal_case(&section.name));
2514                if generated.insert(struct_name) {
2515                    output.push_str(
2516                        &compiler.generate_struct_for_section(section, &resolved_name_map),
2517                    );
2518                    output.push_str("\n\n");
2519                }
2520            }
2521        }
2522
2523        // Generate main entity struct (e.g., OreRound, OreTreasury)
2524        output.push_str(&compiler.generate_main_entity_struct(&resolved_name_map));
2525        output.push_str("\n\n");
2526
2527        let resolved = compiler.generate_resolved_types(
2528            &resolved_name_map,
2529            &mut generated,
2530            Some(&mut account_structs),
2531        );
2532        output.push_str(&resolved);
2533        while !output.ends_with("\n\n") {
2534            output.push('\n');
2535        }
2536    }
2537
2538    // Generate the builtin resolver output structs (SlotHashBytes /
2539    // TokenMetadata) once, for the whole stack.
2540    output.push_str(&render_builtin_resolver_structs(&used_builtins));
2541
2542    // Generate the runtime envelopes (EventWrapper / CaptureWrapper) once.
2543    output.push('\n');
2544    output.push_str(WRAPPER_TYPES);
2545
2546    (output, account_structs)
2547}
2548
2549/// Generate entity.rs with a single Stack impl and per-entity EntityViews.
2550fn generate_stack_entity_rs(
2551    stack_name: &str,
2552    stack_kebab: &str,
2553    entity_specs: &[SerializableStreamSpec],
2554    entity_names: &[String],
2555    config: &RustStackConfig,
2556    exact_views: bool,
2557    programs: Option<&ProgramsCodegen>,
2558) -> String {
2559    let types_import = if config.module_mode {
2560        "super::types"
2561    } else {
2562        "crate::types"
2563    };
2564
2565    let selected_entities = entity_specs
2566        .iter()
2567        .zip(entity_names)
2568        .filter(|(spec, _)| !exact_views || !spec.views.is_empty())
2569        .collect::<Vec<_>>();
2570    let entity_type_imports = selected_entities
2571        .iter()
2572        .map(|(_, name)| (*name).to_string())
2573        .collect::<Vec<_>>();
2574
2575    let url_impl = match &config.url {
2576        Some(url) => format!(
2577            r#"fn url() -> &'static str {{
2578        "{}"
2579    }}"#,
2580            url
2581        ),
2582        None => r#"fn url() -> &'static str {
2583        "" // TODO: Set URL after first deployment in arete.toml
2584    }"#
2585        .to_string(),
2586    };
2587
2588    // Optional HTTP base URL override (account reads / queries / chain reads).
2589    let http_url_impl = match config.http_url.as_deref() {
2590        Some(http_url) if !http_url.is_empty() => format!(
2591            r#"
2592
2593    fn http_url() -> &'static str {{
2594        "{}"
2595    }}"#,
2596            http_url
2597        ),
2598        _ => String::new(),
2599    };
2600
2601    // StackViews struct fields
2602    let views_fields: Vec<String> = selected_entities
2603        .iter()
2604        .map(|(_, name)| {
2605            let snake = to_snake_case(name);
2606            format!("    pub {}: {}EntityViews,", snake, name)
2607        })
2608        .collect();
2609
2610    // Views::from_builder body โ€” clone builder for all but last entity
2611    let views_builder_fields: Vec<String> = selected_entities
2612        .iter()
2613        .enumerate()
2614        .map(|(i, (_, name))| {
2615            let snake = to_snake_case(name);
2616            if i < selected_entities.len() - 1 {
2617                format!(
2618                    "            {}: {}EntityViews {{ builder: builder.clone() }},",
2619                    snake, name
2620                )
2621            } else {
2622                format!("            {}: {}EntityViews {{ builder }},", snake, name)
2623            }
2624        })
2625        .collect();
2626
2627    // Per-entity EntityViews structs
2628    let mut entity_views_structs = Vec::new();
2629    for (i, entity_name) in entity_names.iter().enumerate() {
2630        let spec = &entity_specs[i];
2631        if exact_views && spec.views.is_empty() {
2632            continue;
2633        }
2634
2635        let derived: Vec<_> = spec
2636            .views
2637            .iter()
2638            .filter(|v| {
2639                !v.id.ends_with("/state")
2640                    && !v.id.ends_with("/list")
2641                    && v.id.starts_with(entity_name.as_str())
2642            })
2643            .collect();
2644
2645        let mut methods = Vec::new();
2646
2647        if !exact_views
2648            || spec
2649                .views
2650                .iter()
2651                .any(|view| view.id == format!("{entity_name}/state"))
2652        {
2653            methods.push(format!(
2654                r#"    pub fn state(&self) -> StateView<{entity}> {{
2655        StateView::new(
2656            self.builder.connection().clone(),
2657            self.builder.store().clone(),
2658            "{entity}/state".to_string(),
2659            self.builder.initial_data_timeout(),
2660        )
2661    }}"#,
2662                entity = entity_name
2663            ));
2664        }
2665
2666        if !exact_views
2667            || spec
2668                .views
2669                .iter()
2670                .any(|view| view.id == format!("{entity_name}/list"))
2671        {
2672            methods.push(format!(
2673                r#"
2674    pub fn list(&self) -> ViewHandle<{entity}> {{
2675        self.builder.view("{entity}/list")
2676    }}"#,
2677                entity = entity_name
2678            ));
2679        }
2680
2681        // Derived view methods
2682        for view in &derived {
2683            let view_name = view.id.split('/').nth(1).unwrap_or("unknown");
2684            let method_name = to_snake_case(view_name);
2685            methods.push(format!(
2686                r#"
2687    pub fn {method}(&self) -> ViewHandle<{entity}> {{
2688        self.builder.view("{view_id}")
2689    }}"#,
2690                method = method_name,
2691                entity = entity_name,
2692                view_id = view.id
2693            ));
2694        }
2695
2696        entity_views_structs.push(format!(
2697            r#"
2698pub struct {entity}EntityViews {{
2699    builder: ViewBuilder,
2700}}
2701
2702impl {entity}EntityViews {{
2703{methods}
2704}}"#,
2705            entity = entity_name,
2706            methods = methods.join("\n")
2707        ));
2708    }
2709
2710    let types_use = if entity_type_imports.is_empty() {
2711        String::new()
2712    } else {
2713        format!(
2714            "use {types_import}::{{{}}};\n",
2715            entity_type_imports.join(", ")
2716        )
2717    };
2718    let empty_builder = if selected_entities.is_empty() {
2719        "        let _ = builder;\n"
2720    } else {
2721        ""
2722    };
2723
2724    // Program SDK binding: stacks with generated programs bind a generated
2725    // accessor struct; program-less stacks bind `()`.
2726    let programs_root = if config.module_mode { "super" } else { "crate" };
2727    let (programs_assoc, programs_struct) = match programs {
2728        Some(codegen) => {
2729            let fields: Vec<String> = codegen
2730                .modules
2731                .iter()
2732                .map(|module| {
2733                    format!(
2734                        "    pub {}: {root}::programs::{}::{},",
2735                        module.module_name,
2736                        module.module_name,
2737                        module.struct_name,
2738                        root = programs_root
2739                    )
2740                })
2741                .collect();
2742            let inits: Vec<String> = codegen
2743                .modules
2744                .iter()
2745                .enumerate()
2746                .map(|(index, module)| {
2747                    let builder_expr = if index < codegen.modules.len() - 1 {
2748                        "builder.clone()"
2749                    } else {
2750                        "builder"
2751                    };
2752                    format!(
2753                        "            {}: {root}::programs::{}::{}::from_builder({builder_expr}),",
2754                        module.module_name,
2755                        module.module_name,
2756                        module.struct_name,
2757                        root = programs_root,
2758                        builder_expr = builder_expr
2759                    )
2760                })
2761                .collect();
2762            (
2763                format!("type Programs = {}StackPrograms;", stack_name),
2764                format!(
2765                    r#"
2766
2767pub struct {stack}StackPrograms {{
2768{fields}
2769}}
2770
2771impl arete_sdk::Programs for {stack}StackPrograms {{
2772    fn from_builder(builder: arete_sdk::ProgramBuilder) -> Self {{
2773        Self {{
2774{inits}
2775        }}
2776    }}
2777}}"#,
2778                    stack = stack_name,
2779                    fields = fields.join("\n"),
2780                    inits = inits.join("\n")
2781                ),
2782            )
2783        }
2784        None => ("type Programs = ();".to_string(), String::new()),
2785    };
2786
2787    format!(
2788        r#"{types_use}use arete_sdk::{{Stack, StateView, ViewBuilder, ViewHandle, Views}};
2789
2790pub struct {stack}Stack;
2791
2792impl Stack for {stack}Stack {{
2793    type Views = {stack}StackViews;
2794    {programs_assoc}
2795
2796    fn name() -> &'static str {{
2797        "{stack_kebab}"
2798    }}
2799
2800    {url_impl}{http_url_impl}
2801}}
2802
2803pub struct {stack}StackViews {{
2804{views_fields}
2805}}
2806
2807impl Views for {stack}StackViews {{
2808    fn from_builder(builder: ViewBuilder) -> Self {{
2809{empty_builder}        Self {{
2810{views_builder}
2811        }}
2812    }}
2813}}
2814{entity_views}{programs_struct}"#,
2815        types_use = types_use,
2816        stack = stack_name,
2817        stack_kebab = stack_kebab,
2818        programs_assoc = programs_assoc,
2819        url_impl = url_impl,
2820        http_url_impl = http_url_impl,
2821        views_fields = views_fields.join("\n"),
2822        views_builder = views_builder_fields.join("\n"),
2823        entity_views = entity_views_structs.join("\n"),
2824        empty_builder = empty_builder,
2825        programs_struct = programs_struct,
2826    )
2827}
2828
2829// ============================================================================
2830// Program SDK generation (programs.rs)
2831// ============================================================================
2832
2833/// One generated program module and the accessor struct it exports.
2834#[derive(Debug, Clone)]
2835pub(crate) struct ProgramModule {
2836    module_name: String,
2837    struct_name: String,
2838}
2839
2840/// Result of generating the `programs` module for a stack.
2841#[derive(Debug, Clone)]
2842pub(crate) struct ProgramsCodegen {
2843    code: String,
2844    modules: Vec<ProgramModule>,
2845}
2846
2847/// Which `arete_sdk::instruction` items a generated program module references.
2848#[derive(Debug, Default)]
2849struct ProgramImports {
2850    account_meta: bool,
2851    arg_schema: bool,
2852    pda: bool,
2853    error_metadata: bool,
2854}
2855
2856/// A parsed instruction argument type.
2857#[derive(Debug, Clone)]
2858struct RustParsedArg {
2859    /// `ArgType::โ€ฆ` constructor expression for the handler schema.
2860    schema: String,
2861    /// Rust type for the typed params struct field.
2862    param_type: String,
2863    /// Whether the type is representable by the core serializer.
2864    supported: bool,
2865}
2866
2867fn rust_unsupported() -> RustParsedArg {
2868    RustParsedArg {
2869        schema: "ArgType::U8".to_string(),
2870        param_type: "()".to_string(),
2871        supported: false,
2872    }
2873}
2874
2875fn rust_prim(schema: &str, param_type: &str) -> RustParsedArg {
2876    RustParsedArg {
2877        schema: schema.to_string(),
2878        param_type: param_type.to_string(),
2879        supported: true,
2880    }
2881}
2882
2883/// Render a Rust string literal (quoted and escaped).
2884fn rust_string_literal(value: &str) -> String {
2885    format!("{:?}", value)
2886}
2887
2888/// Resolver for IDL-defined types (structs/enums) referenced by instruction
2889/// args. Resolved types are inlined into arg schemas as `ArgType::Struct` /
2890/// `ArgType::Enum` expressions; the typed params field for such args is
2891/// `serde_json::Value`. Mirrors the TypeScript `DefinedTypes` parsing rules.
2892struct RustDefinedTypes<'a> {
2893    /// IDL type definitions by name, first-wins across programs.
2894    defs: BTreeMap<String, &'a IdlTypeDefSnapshot>,
2895    /// lowercase name -> canonical key, for case-insensitive fallback lookup.
2896    lower: BTreeMap<String, String>,
2897    /// Memoized resolutions by original IDL name (`None` = unsupported).
2898    resolved: BTreeMap<String, Option<RustParsedArg>>,
2899    /// Names currently being resolved (cycle guard).
2900    visiting: HashSet<String>,
2901}
2902
2903impl<'a> RustDefinedTypes<'a> {
2904    fn new(idls: &'a [IdlSnapshot]) -> Self {
2905        let mut defs: BTreeMap<String, &'a IdlTypeDefSnapshot> = BTreeMap::new();
2906        let mut lower: BTreeMap<String, String> = BTreeMap::new();
2907        for idl in idls {
2908            for def in &idl.types {
2909                if !defs.contains_key(def.name.as_str()) {
2910                    defs.insert(def.name.clone(), def);
2911                    lower.insert(def.name.to_lowercase(), def.name.clone());
2912                }
2913            }
2914        }
2915        RustDefinedTypes {
2916            defs,
2917            lower,
2918            resolved: BTreeMap::new(),
2919            visiting: HashSet::new(),
2920        }
2921    }
2922
2923    /// Parse a stringified Rust-ish arg type (what `to_rust_type_string`
2924    /// produces), resolving bare names against the IDL type definitions.
2925    fn parse_arg_type(&mut self, raw: &str) -> RustParsedArg {
2926        let t = raw.trim().trim_start_matches('&').trim();
2927
2928        // Generic wrappers: Option<T>, Vec<T>.
2929        if let Some((name, inner)) = split_generic(t) {
2930            match name {
2931                "Option" => {
2932                    let inner = self.parse_arg_type(inner);
2933                    return RustParsedArg {
2934                        schema: format!("ArgType::Option(Box::new({}))", inner.schema),
2935                        param_type: format!("Option<{}>", inner.param_type),
2936                        supported: inner.supported,
2937                    };
2938                }
2939                "Vec" => {
2940                    let inner = self.parse_arg_type(inner);
2941                    return RustParsedArg {
2942                        schema: format!("ArgType::Vec(Box::new({}))", inner.schema),
2943                        param_type: format!("Vec<{}>", inner.param_type),
2944                        supported: inner.supported,
2945                    };
2946                }
2947                _ => return rust_unsupported(),
2948            }
2949        }
2950
2951        // Fixed-size array: [T; N].
2952        if let Some(stripped) = t.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
2953            if let Some((ty, n)) = stripped.rsplit_once(';') {
2954                let inner = self.parse_arg_type(ty.trim());
2955                let n = n.trim();
2956                if n.parse::<usize>().is_ok() {
2957                    return RustParsedArg {
2958                        schema: format!("ArgType::Array(Box::new({}), {})", inner.schema, n),
2959                        param_type: format!("Vec<{}>", inner.param_type),
2960                        supported: inner.supported,
2961                    };
2962                }
2963            }
2964        }
2965
2966        // Primitive (possibly path-qualified, e.g. solana_pubkey::Pubkey).
2967        let last = t.rsplit("::").next().unwrap_or(t);
2968        match last {
2969            "u8" => rust_prim("ArgType::U8", "u8"),
2970            "u16" => rust_prim("ArgType::U16", "u16"),
2971            "u32" => rust_prim("ArgType::U32", "u32"),
2972            "u64" => rust_prim("ArgType::U64", "u64"),
2973            // serde_json cannot carry 128-bit integers losslessly; the core
2974            // serializer accepts decimal strings for them.
2975            "u128" => rust_prim("ArgType::U128", "String"),
2976            "i8" => rust_prim("ArgType::I8", "i8"),
2977            "i16" => rust_prim("ArgType::I16", "i16"),
2978            "i32" => rust_prim("ArgType::I32", "i32"),
2979            "i64" => rust_prim("ArgType::I64", "i64"),
2980            "i128" => rust_prim("ArgType::I128", "String"),
2981            "f32" => rust_prim("ArgType::F32", "f32"),
2982            "f64" => rust_prim("ArgType::F64", "f64"),
2983            "bool" => rust_prim("ArgType::Bool", "bool"),
2984            "String" | "string" | "str" => rust_prim("ArgType::String", "String"),
2985            "Pubkey" | "pubkey" | "PublicKey" | "publicKey" => {
2986                rust_prim("ArgType::Pubkey", "String")
2987            }
2988            "bytes" => rust_prim("ArgType::Bytes", "Vec<u8>"),
2989            _ => self.resolve_defined(last).unwrap_or_else(rust_unsupported),
2990        }
2991    }
2992
2993    /// Parse an IDL snapshot type (used inside struct fields / enum variants).
2994    fn parse_snapshot_type(&mut self, t: &IdlTypeSnapshot) -> RustParsedArg {
2995        match t {
2996            IdlTypeSnapshot::Simple(s) => self.parse_arg_type(s),
2997            IdlTypeSnapshot::Option(o) => {
2998                let inner = self.parse_snapshot_type(&o.option);
2999                RustParsedArg {
3000                    schema: format!("ArgType::Option(Box::new({}))", inner.schema),
3001                    param_type: format!("Option<{}>", inner.param_type),
3002                    supported: inner.supported,
3003                }
3004            }
3005            IdlTypeSnapshot::Vec(v) => {
3006                let inner = self.parse_snapshot_type(&v.vec);
3007                RustParsedArg {
3008                    schema: format!("ArgType::Vec(Box::new({}))", inner.schema),
3009                    param_type: format!("Vec<{}>", inner.param_type),
3010                    supported: inner.supported,
3011                }
3012            }
3013            IdlTypeSnapshot::Array(arr) => {
3014                let mut element: Option<RustParsedArg> = None;
3015                let mut size: Option<u32> = None;
3016                for part in &arr.array {
3017                    match part {
3018                        IdlArrayElementSnapshot::Type(inner) => {
3019                            element = Some(self.parse_snapshot_type(inner))
3020                        }
3021                        IdlArrayElementSnapshot::TypeName(name) => {
3022                            element = Some(self.parse_arg_type(name))
3023                        }
3024                        IdlArrayElementSnapshot::Size(n) => size = Some(*n),
3025                    }
3026                }
3027                match (element, size) {
3028                    (Some(inner), Some(n)) => RustParsedArg {
3029                        schema: format!("ArgType::Array(Box::new({}), {})", inner.schema, n),
3030                        param_type: format!("Vec<{}>", inner.param_type),
3031                        supported: inner.supported,
3032                    },
3033                    _ => rust_unsupported(),
3034                }
3035            }
3036            IdlTypeSnapshot::HashMap(map) => {
3037                let key = self.parse_snapshot_type(&map.hash_map.0);
3038                let value = self.parse_snapshot_type(&map.hash_map.1);
3039                if !key.supported || key.schema != "ArgType::String" || !value.supported {
3040                    rust_unsupported()
3041                } else {
3042                    RustParsedArg {
3043                        schema: format!(
3044                            "ArgType::HashMap(Box::new({}), Box::new({}))",
3045                            key.schema, value.schema
3046                        ),
3047                        param_type: "serde_json::Value".to_string(),
3048                        supported: true,
3049                    }
3050                }
3051            }
3052            IdlTypeSnapshot::Defined(d) => {
3053                let name = match &d.defined {
3054                    IdlDefinedInnerSnapshot::Named { name } => name.as_str(),
3055                    IdlDefinedInnerSnapshot::Simple(s) => s.as_str(),
3056                };
3057                self.resolve_defined(name).unwrap_or_else(rust_unsupported)
3058            }
3059        }
3060    }
3061
3062    /// Resolve a bare type name against the IDL type definitions. Returns
3063    /// `None` when unsupported (unknown, recursive, tuple struct, โ€ฆ).
3064    fn resolve_defined(&mut self, name: &str) -> Option<RustParsedArg> {
3065        if let Some(cached) = self.resolved.get(name) {
3066            return cached.clone();
3067        }
3068        if self.visiting.contains(name) {
3069            // Recursive types are not supported by instruction codegen.
3070            return None;
3071        }
3072
3073        let key = if self.defs.contains_key(name) {
3074            name.to_string()
3075        } else {
3076            match self.lower.get(&name.to_lowercase()) {
3077                Some(canonical) => canonical.clone(),
3078                None => {
3079                    self.resolved.insert(name.to_string(), None);
3080                    return None;
3081                }
3082            }
3083        };
3084
3085        self.visiting.insert(key.clone());
3086        let def = self.defs[&key];
3087        let result = match &def.type_def {
3088            IdlTypeDefKindSnapshot::Struct { fields, .. } => {
3089                let fields = fields.clone();
3090                self.resolve_struct(&fields)
3091            }
3092            IdlTypeDefKindSnapshot::TupleStruct { .. } => None,
3093            IdlTypeDefKindSnapshot::Enum { variants, .. } => {
3094                let variants = variants.clone();
3095                self.resolve_enum(&variants)
3096            }
3097        };
3098        self.visiting.remove(&key);
3099        self.resolved.insert(name.to_string(), result.clone());
3100        if name != key {
3101            self.resolved.insert(key, result.clone());
3102        }
3103        result
3104    }
3105
3106    fn resolve_struct(&mut self, fields: &[IdlFieldSnapshot]) -> Option<RustParsedArg> {
3107        let mut field_exprs: Vec<String> = Vec::new();
3108        for field in fields {
3109            let parsed = self.parse_snapshot_type(&field.type_);
3110            if !parsed.supported {
3111                return None;
3112            }
3113            field_exprs.push(format!(
3114                "ArgField {{ name: {}.to_string(), ty: {} }}",
3115                rust_string_literal(&field.name),
3116                parsed.schema
3117            ));
3118        }
3119        Some(RustParsedArg {
3120            schema: format!("ArgType::Struct(vec![{}])", field_exprs.join(", ")),
3121            param_type: "serde_json::Value".to_string(),
3122            supported: true,
3123        })
3124    }
3125
3126    fn resolve_enum(&mut self, variants: &[IdlEnumVariantSnapshot]) -> Option<RustParsedArg> {
3127        let mut variant_exprs: Vec<String> = Vec::new();
3128        for variant in variants {
3129            let name_literal = rust_string_literal(&variant.name);
3130            if variant.fields.is_empty() {
3131                variant_exprs.push(format!(
3132                    "EnumVariantDef {{ name: {}.to_string(), kind: EnumVariantKind::Unit }}",
3133                    name_literal
3134                ));
3135                continue;
3136            }
3137
3138            let named: Vec<_> = variant
3139                .fields
3140                .iter()
3141                .filter_map(|field| match field {
3142                    IdlEnumVariantFieldSnapshot::Named(field) => Some(field),
3143                    IdlEnumVariantFieldSnapshot::Tuple(_) => None,
3144                })
3145                .collect();
3146
3147            if named.len() == variant.fields.len() {
3148                let mut field_exprs: Vec<String> = Vec::new();
3149                for field in named {
3150                    let parsed = self.parse_snapshot_type(&field.type_);
3151                    if !parsed.supported {
3152                        return None;
3153                    }
3154                    field_exprs.push(format!(
3155                        "ArgField {{ name: {}.to_string(), ty: {} }}",
3156                        rust_string_literal(&field.name),
3157                        parsed.schema
3158                    ));
3159                }
3160                variant_exprs.push(format!(
3161                    "EnumVariantDef {{ name: {}.to_string(), kind: EnumVariantKind::Struct(vec![{}]) }}",
3162                    name_literal,
3163                    field_exprs.join(", ")
3164                ));
3165            } else if named.is_empty() {
3166                let mut element_exprs: Vec<String> = Vec::new();
3167                for field in &variant.fields {
3168                    let IdlEnumVariantFieldSnapshot::Tuple(ty) = field else {
3169                        unreachable!("named.is_empty() guarantees tuple fields");
3170                    };
3171                    let parsed = self.parse_snapshot_type(ty);
3172                    if !parsed.supported {
3173                        return None;
3174                    }
3175                    element_exprs.push(parsed.schema);
3176                }
3177                variant_exprs.push(format!(
3178                    "EnumVariantDef {{ name: {}.to_string(), kind: EnumVariantKind::Tuple(vec![{}]) }}",
3179                    name_literal,
3180                    element_exprs.join(", ")
3181                ));
3182            } else {
3183                // Mixed named and tuple fields are not supported.
3184                return None;
3185            }
3186        }
3187        Some(RustParsedArg {
3188            schema: format!("ArgType::Enum(vec![{}])", variant_exprs.join(", ")),
3189            param_type: "serde_json::Value".to_string(),
3190            supported: true,
3191        })
3192    }
3193}
3194
3195/// Whether any emitted schema expression references `ArgField` /
3196/// `EnumVariantDef` (defined struct/enum types were inlined).
3197fn schema_uses_defined_types(schema: &str) -> bool {
3198    schema.contains("ArgField") || schema.contains("EnumVariantDef")
3199}
3200
3201/// How a mapped account surfaces in the typed params struct.
3202#[derive(Debug, Clone, Copy, PartialEq)]
3203enum RustAccountFieldKind {
3204    /// Signer slot: optional address override (payer fallback applies).
3205    Signer,
3206    /// Required user-provided account address.
3207    Required,
3208    /// Optional user-provided account address.
3209    Optional,
3210}
3211
3212/// Result of mapping a single instruction account.
3213struct MappedRustAccount {
3214    /// `AccountMeta { โ€ฆ },` literal, indented for the handler's accounts vec.
3215    literal: String,
3216    /// Params field for caller-supplied addresses.
3217    field: Option<(String, RustAccountFieldKind)>,
3218    /// Human-readable notes surfaced in the typed builder's doc comment.
3219    notes: Vec<String>,
3220    /// Whether the emitted resolution references `PdaConfig` / `PdaSeed`.
3221    uses_pda: bool,
3222}
3223
3224fn rust_account_meta_literal(
3225    acc: &InstructionAccountDef,
3226    resolution: &str,
3227    comment: Option<&str>,
3228) -> String {
3229    let mut out = String::new();
3230    if let Some(comment) = comment {
3231        out.push_str(&format!("                // [arete codegen] {}\n", comment));
3232    }
3233    out.push_str(&format!(
3234        "                AccountMeta {{\n                    name: {name}.to_string(),\n                    is_signer: {is_signer},\n                    is_writable: {is_writable},\n                    resolution: {resolution},\n                    is_optional: {is_optional},\n                }},",
3235        name = rust_string_literal(&acc.name),
3236        is_signer = acc.is_signer,
3237        is_writable = acc.is_writable,
3238        resolution = resolution,
3239        is_optional = acc.is_optional,
3240    ));
3241    out
3242}
3243
3244fn map_rust_account(
3245    acc: &InstructionAccountDef,
3246    pda_lookup: &BTreeMap<&str, &PdaDefinition>,
3247    account_names: &HashSet<&str>,
3248    arg_types: &BTreeMap<&str, &str>,
3249) -> MappedRustAccount {
3250    let user_field_kind = if acc.is_optional {
3251        RustAccountFieldKind::Optional
3252    } else {
3253        RustAccountFieldKind::Required
3254    };
3255    let degraded = |reason: String| -> MappedRustAccount {
3256        let note = format!(
3257            "account `{}` degraded to user-provided ({})",
3258            acc.name, reason
3259        );
3260        MappedRustAccount {
3261            literal: rust_account_meta_literal(acc, "AccountResolution::UserProvided", Some(&note)),
3262            field: Some((acc.name.clone(), user_field_kind)),
3263            notes: vec![note],
3264            uses_pda: false,
3265        }
3266    };
3267
3268    match &acc.resolution {
3269        AccountResolution::Signer => MappedRustAccount {
3270            literal: rust_account_meta_literal(acc, "AccountResolution::Signer", None),
3271            field: Some((acc.name.clone(), RustAccountFieldKind::Signer)),
3272            notes: Vec::new(),
3273            uses_pda: false,
3274        },
3275        AccountResolution::Known { address } => MappedRustAccount {
3276            literal: rust_account_meta_literal(
3277                acc,
3278                &format!(
3279                    "AccountResolution::Known({}.to_string())",
3280                    rust_string_literal(address)
3281                ),
3282                None,
3283            ),
3284            field: None,
3285            notes: Vec::new(),
3286            uses_pda: false,
3287        },
3288        AccountResolution::UserProvided => MappedRustAccount {
3289            literal: rust_account_meta_literal(acc, "AccountResolution::UserProvided", None),
3290            field: Some((acc.name.clone(), user_field_kind)),
3291            notes: Vec::new(),
3292            uses_pda: false,
3293        },
3294        AccountResolution::PdaInline { seeds, program_id } => {
3295            match build_rust_pda_config(seeds, program_id.as_deref(), account_names, arg_types) {
3296                Ok((resolution, notes)) => MappedRustAccount {
3297                    literal: rust_account_meta_literal(acc, &resolution, None),
3298                    field: None,
3299                    notes,
3300                    uses_pda: true,
3301                },
3302                Err(reason) => degraded(reason),
3303            }
3304        }
3305        AccountResolution::PdaRef { pda_name } => match pda_lookup.get(pda_name.as_str()) {
3306            Some(def) => {
3307                match build_rust_pda_config(
3308                    &def.seeds,
3309                    def.program_id.as_deref(),
3310                    account_names,
3311                    arg_types,
3312                ) {
3313                    Ok((resolution, notes)) => MappedRustAccount {
3314                        literal: rust_account_meta_literal(acc, &resolution, None),
3315                        field: None,
3316                        notes,
3317                        uses_pda: true,
3318                    },
3319                    Err(reason) => degraded(format!("PDA '{}': {}", pda_name, reason)),
3320                }
3321            }
3322            None => degraded(format!("references unknown PDA '{}'", pda_name)),
3323        },
3324    }
3325}
3326
3327/// Build an `AccountResolution::Pda(PdaConfig { โ€ฆ })` expression from seed
3328/// definitions. Returns `Err(reason)` when the PDA cannot be represented by
3329/// the core resolver, so the caller can degrade to user-provided.
3330fn build_rust_pda_config(
3331    seeds: &[PdaSeedDef],
3332    program_id: Option<&str>,
3333    account_names: &HashSet<&str>,
3334    arg_types: &BTreeMap<&str, &str>,
3335) -> Result<(String, Vec<String>), String> {
3336    let mut seed_exprs: Vec<String> = Vec::new();
3337    let mut notes: Vec<String> = Vec::new();
3338    for seed in seeds {
3339        match seed {
3340            PdaSeedDef::Literal { value } => {
3341                seed_exprs.push(format!(
3342                    "PdaSeed::Literal({}.to_string())",
3343                    rust_string_literal(value)
3344                ));
3345            }
3346            PdaSeedDef::Bytes { value } => {
3347                let bytes: Vec<String> = value.iter().map(|b| b.to_string()).collect();
3348                seed_exprs.push(format!("PdaSeed::Bytes(vec![{}])", bytes.join(", ")));
3349            }
3350            PdaSeedDef::AccountRef { account_name } => {
3351                if account_name.contains('.') {
3352                    return Err(format!(
3353                        "seed references account field '{}' which is not supported for auto-resolution",
3354                        account_name
3355                    ));
3356                }
3357                if !account_names.contains(account_name.as_str()) {
3358                    return Err(format!(
3359                        "seed references account '{}' not present in this instruction",
3360                        account_name
3361                    ));
3362                }
3363                seed_exprs.push(format!(
3364                    "PdaSeed::AccountRef({}.to_string())",
3365                    rust_string_literal(account_name)
3366                ));
3367            }
3368            PdaSeedDef::ArgRef { arg_name, arg_type } => {
3369                let arg_root = arg_name.split('.').next().unwrap_or(arg_name.as_str());
3370                let present =
3371                    arg_types.contains_key(arg_name.as_str()) || arg_types.contains_key(arg_root);
3372                // Prefer the seed's declared type; fall back to the
3373                // instruction arg's type (Anchor seeds carry no type info).
3374                let raw_type = arg_type
3375                    .as_deref()
3376                    .or_else(|| arg_types.get(arg_name.as_str()).copied())
3377                    .or_else(|| arg_types.get(arg_root).copied());
3378                let canonical = raw_type.and_then(normalize_seed_arg_type);
3379                if !present {
3380                    if canonical.is_none() {
3381                        return Err(format!(
3382                            "seed helper arg '{}' is not present in this instruction and has no primitive type information",
3383                            arg_name
3384                        ));
3385                    }
3386                    notes.push(format!(
3387                        "seed input `{}` must be supplied via the `resolve` key when building through the raw handler",
3388                        arg_name
3389                    ));
3390                }
3391                match canonical {
3392                    Some(canonical) => seed_exprs.push(format!(
3393                        "PdaSeed::ArgRef {{ arg: {}.to_string(), arg_type: Some({}.to_string()) }}",
3394                        rust_string_literal(arg_name),
3395                        rust_string_literal(&canonical)
3396                    )),
3397                    None => {
3398                        notes.push(format!(
3399                            "seed arg `{}` has non-primitive type '{}'; the runtime will use heuristic encoding",
3400                            arg_name,
3401                            raw_type.unwrap_or("<unknown>")
3402                        ));
3403                        seed_exprs.push(format!(
3404                            "PdaSeed::ArgRef {{ arg: {}.to_string(), arg_type: None }}",
3405                            rust_string_literal(arg_name)
3406                        ));
3407                    }
3408                }
3409            }
3410        }
3411    }
3412
3413    let program_expr = match program_id {
3414        Some(pid) => format!("Some({}.to_string())", rust_string_literal(pid)),
3415        None => "None".to_string(),
3416    };
3417    Ok((
3418        format!(
3419            "AccountResolution::Pda(PdaConfig {{ program_id: {}, seeds: vec![{}] }})",
3420            program_expr,
3421            seed_exprs.join(", ")
3422        ),
3423        notes,
3424    ))
3425}
3426
3427/// Generated code for one instruction: module items plus the accessor method.
3428struct RustInstructionBlock {
3429    code: String,
3430    method: String,
3431    uses_defined_types: bool,
3432}
3433
3434fn generate_rust_instruction_block(
3435    instr: &InstructionDef,
3436    errors: &[IdlErrorSnapshot],
3437    pda_lookup: &BTreeMap<&str, &PdaDefinition>,
3438    parser: &mut RustDefinedTypes<'_>,
3439    needs: &mut ProgramImports,
3440) -> Result<RustInstructionBlock, String> {
3441    // --- Parse args; skip the whole instruction on unsupported types. ---
3442    let mut parsed_args: Vec<(&InstructionArgDef, RustParsedArg)> = Vec::new();
3443    for arg in &instr.args {
3444        let parsed = parser.parse_arg_type(&arg.arg_type);
3445        if !parsed.supported {
3446            return Err(format!(
3447                "arg '{}' has unsupported type '{}'",
3448                arg.name, arg.arg_type
3449            ));
3450        }
3451        parsed_args.push((arg, parsed));
3452    }
3453
3454    // --- Map accounts. ---
3455    let account_names: HashSet<&str> = instr.accounts.iter().map(|a| a.name.as_str()).collect();
3456    let arg_types: BTreeMap<&str, &str> = instr
3457        .args
3458        .iter()
3459        .map(|a| (a.name.as_str(), a.arg_type.as_str()))
3460        .collect();
3461
3462    let mut account_literals: Vec<String> = Vec::new();
3463    let mut account_fields: Vec<(String, RustAccountFieldKind)> = Vec::new();
3464    let mut notes: Vec<String> = Vec::new();
3465    for acc in &instr.accounts {
3466        let mapped = map_rust_account(acc, pda_lookup, &account_names, &arg_types);
3467        account_literals.push(mapped.literal);
3468        if let Some(field) = mapped.field {
3469            account_fields.push(field);
3470        }
3471        notes.extend(mapped.notes);
3472        if mapped.uses_pda {
3473            needs.pda = true;
3474        }
3475    }
3476    if !instr.accounts.is_empty() {
3477        needs.account_meta = true;
3478    }
3479    if !instr.args.is_empty() {
3480        needs.arg_schema = true;
3481    }
3482    if !errors.is_empty() {
3483        needs.error_metadata = true;
3484    }
3485
3486    let fn_name = to_snake_case(&instr.name);
3487    let pascal = to_pascal_case(&instr.name);
3488    let params_name = format!("{}Params", pascal);
3489
3490    // --- Typed params struct: args first, then caller-supplied accounts.
3491    // Instruction args win name collisions (mirrors the TS SDK's params
3492    // precedence in `splitParams`). ---
3493    let arg_name_set: HashSet<&str> = instr.args.iter().map(|a| a.name.as_str()).collect();
3494    let mut used_field_names: HashSet<String> = HashSet::new();
3495    let mut param_fields: Vec<String> = Vec::new();
3496    let mut uses_defined_types = false;
3497    for (arg, parsed) in &parsed_args {
3498        let field_name = to_snake_case(&arg.name);
3499        used_field_names.insert(field_name.clone());
3500        uses_defined_types |= schema_uses_defined_types(&parsed.schema);
3501        let mut lines = Vec::new();
3502        if field_name != arg.name {
3503            lines.push(format!(
3504                "        #[serde(rename = {})]",
3505                rust_string_literal(&arg.name)
3506            ));
3507        }
3508        lines.push(format!(
3509            "        pub {}: {},",
3510            field_name, parsed.param_type
3511        ));
3512        param_fields.push(lines.join("\n"));
3513    }
3514    for (name, kind) in &account_fields {
3515        if arg_name_set.contains(name.as_str()) {
3516            notes.push(format!(
3517                "account `{}` shares its name with an instruction arg and has no typed override field",
3518                name
3519            ));
3520            continue;
3521        }
3522        let field_name = to_snake_case(name);
3523        if !used_field_names.insert(field_name.clone()) {
3524            notes.push(format!(
3525                "account `{}` collides with another params field and has no typed override field",
3526                name
3527            ));
3528            continue;
3529        }
3530        let mut lines = Vec::new();
3531        match kind {
3532            RustAccountFieldKind::Signer => lines.push(format!(
3533                "        /// Optional address override for the `{}` signer (defaults to the payer).",
3534                name
3535            )),
3536            RustAccountFieldKind::Required => {
3537                lines.push(format!("        /// Address of the `{}` account.", name))
3538            }
3539            RustAccountFieldKind::Optional => lines.push(format!(
3540                "        /// Optional address of the `{}` account.",
3541                name
3542            )),
3543        }
3544        if field_name != *name {
3545            lines.push(format!(
3546                "        #[serde(rename = {})]",
3547                rust_string_literal(name)
3548            ));
3549        }
3550        match kind {
3551            RustAccountFieldKind::Required => {
3552                lines.push(format!("        pub {}: String,", field_name))
3553            }
3554            _ => {
3555                lines.push(
3556                    "        #[serde(skip_serializing_if = \"Option::is_none\")]".to_string(),
3557                );
3558                lines.push(format!("        pub {}: Option<String>,", field_name));
3559            }
3560        }
3561        param_fields.push(lines.join("\n"));
3562    }
3563
3564    let params_struct = if param_fields.is_empty() {
3565        format!(
3566            "    /// Typed params for `{name}` (no args or caller-supplied accounts).\n    #[derive(Debug, Clone, Serialize, Default)]\n    pub struct {params_name} {{}}",
3567            name = instr.name,
3568            params_name = params_name
3569        )
3570    } else {
3571        format!(
3572            "    /// Typed params for `{name}`: instruction args plus overridable accounts.\n    #[derive(Debug, Clone, Serialize, Default)]\n    pub struct {params_name} {{\n{fields}\n    }}",
3573            name = instr.name,
3574            params_name = params_name,
3575            fields = param_fields.join("\n")
3576        )
3577    };
3578
3579    // --- Typed builder fn. ---
3580    let mut doc_lines: Vec<String> = instr
3581        .docs
3582        .iter()
3583        .map(|line| line.trim().to_string())
3584        .collect();
3585    if doc_lines.is_empty() {
3586        doc_lines.push(format!("Builds the `{}` instruction.", instr.name));
3587    }
3588    if !notes.is_empty() {
3589        doc_lines.push(String::new());
3590        doc_lines.push("Codegen notes:".to_string());
3591        for note in &notes {
3592            doc_lines.push(format!("- {}", note));
3593        }
3594    }
3595    let docs = doc_lines
3596        .iter()
3597        .map(|line| {
3598            if line.is_empty() {
3599                "    ///".to_string()
3600            } else {
3601                format!("    /// {}", line)
3602            }
3603        })
3604        .collect::<Vec<_>>()
3605        .join("\n");
3606
3607    let typed_fn = format!(
3608        "{docs}\n    pub fn {fn_name}(params: {params_name}) -> Result<BuiltInstruction, InstructionError> {{\n        let params = serde_json::to_value(params).map_err(|error| InstructionError::InvalidValue {{\n            context: \"params\".to_string(),\n            message: error.to_string(),\n        }})?;\n        {fn_name}_handler().build(params)\n    }}",
3609        docs = docs,
3610        fn_name = fn_name,
3611        params_name = params_name
3612    );
3613
3614    // --- Handler literal. ---
3615    let discriminator = instr
3616        .discriminator
3617        .iter()
3618        .map(|b| b.to_string())
3619        .collect::<Vec<_>>()
3620        .join(", ");
3621    let accounts_literal = if account_literals.is_empty() {
3622        "vec![]".to_string()
3623    } else {
3624        format!("vec![\n{}\n            ]", account_literals.join("\n"))
3625    };
3626    let args_literal = if parsed_args.is_empty() {
3627        "vec![]".to_string()
3628    } else {
3629        let entries: Vec<String> = parsed_args
3630            .iter()
3631            .map(|(arg, parsed)| {
3632                format!(
3633                    "                ArgSchema {{ name: {}.to_string(), ty: {} }},",
3634                    rust_string_literal(&arg.name),
3635                    parsed.schema
3636                )
3637            })
3638            .collect();
3639        format!("vec![\n{}\n            ]", entries.join("\n"))
3640    };
3641    let errors_literal = if errors.is_empty() {
3642        "vec![]".to_string()
3643    } else {
3644        let entries: Vec<String> = errors
3645            .iter()
3646            .map(|error| {
3647                format!(
3648                    "                ErrorMetadata {{ code: {}, name: {}.to_string(), msg: {}.to_string() }},",
3649                    error.code,
3650                    rust_string_literal(&error.name),
3651                    rust_string_literal(error.msg.as_deref().unwrap_or(""))
3652                )
3653            })
3654            .collect();
3655        format!("vec![\n{}\n            ]", entries.join("\n"))
3656    };
3657
3658    let handler_fn = format!(
3659        "    /// Raw instruction handler for `{name}`.\n    pub fn {fn_name}_handler() -> InstructionHandler {{\n        InstructionHandler {{\n            program_id: PROGRAM_ID.to_string(),\n            discriminator: vec![{discriminator}],\n            accounts: {accounts},\n            args: {args},\n            errors: {errors},\n        }}\n    }}",
3660        name = instr.name,
3661        fn_name = fn_name,
3662        discriminator = discriminator,
3663        accounts = accounts_literal,
3664        args = args_literal,
3665        errors = errors_literal
3666    );
3667
3668    let method = format!(
3669        "        pub fn {fn_name}(&self, params: {params_name}) -> Result<BuiltInstruction, InstructionError> {{\n            {fn_name}(params)\n        }}",
3670        fn_name = fn_name,
3671        params_name = params_name
3672    );
3673
3674    Ok(RustInstructionBlock {
3675        code: format!("{}\n\n{}\n\n{}", params_struct, typed_fn, handler_fn),
3676        method,
3677        uses_defined_types,
3678    })
3679}
3680
3681/// Generate the `pdas` helper module for one program. Returns `None` when the
3682/// program declares no PDAs.
3683fn generate_rust_pdas_module(pdas: &BTreeMap<String, PdaDefinition>) -> Option<String> {
3684    if pdas.is_empty() {
3685        return None;
3686    }
3687
3688    let mut fns: Vec<String> = Vec::new();
3689    let mut needs_serialize = false;
3690    let mut needs_program_id = false;
3691    for def in pdas.values() {
3692        let fn_name = to_snake_case(&def.name);
3693        let mut params: Vec<(String, String)> = Vec::new();
3694        let mut seed_exprs: Vec<String> = Vec::new();
3695        for seed in &def.seeds {
3696            match seed {
3697                PdaSeedDef::Literal { value } => {
3698                    seed_exprs.push(format!(
3699                        "{}.as_bytes().to_vec()",
3700                        rust_string_literal(value)
3701                    ));
3702                }
3703                PdaSeedDef::Bytes { value } => {
3704                    let bytes: Vec<String> = value.iter().map(|b| b.to_string()).collect();
3705                    seed_exprs.push(format!("vec![{}]", bytes.join(", ")));
3706                }
3707                PdaSeedDef::AccountRef { account_name } => {
3708                    let param = to_snake_case(account_name);
3709                    if !params.iter().any(|(name, _)| *name == param) {
3710                        params.push((param.clone(), "&str".to_string()));
3711                    }
3712                    needs_serialize = true;
3713                    seed_exprs.push(format!(
3714                        "serialize_seed_value(&serde_json::json!({}), Some(\"pubkey\"))?",
3715                        param
3716                    ));
3717                }
3718                PdaSeedDef::ArgRef { arg_name, arg_type } => {
3719                    let param = to_snake_case(arg_name);
3720                    let canonical = arg_type.as_deref().and_then(normalize_seed_arg_type);
3721                    let (param_type, hint) = match canonical.as_deref() {
3722                        Some("pubkey") => ("&str", "Some(\"pubkey\")".to_string()),
3723                        Some("string") => ("&str", "Some(\"string\")".to_string()),
3724                        Some(int) if int.starts_with('i') => {
3725                            ("i64", format!("Some({})", rust_string_literal(int)))
3726                        }
3727                        Some(int) => ("u64", format!("Some({})", rust_string_literal(int))),
3728                        None => ("&str", "None".to_string()),
3729                    };
3730                    if !params.iter().any(|(name, _)| *name == param) {
3731                        params.push((param.clone(), param_type.to_string()));
3732                    }
3733                    needs_serialize = true;
3734                    seed_exprs.push(format!(
3735                        "serialize_seed_value(&serde_json::json!({}), {})?",
3736                        param, hint
3737                    ));
3738                }
3739            }
3740        }
3741
3742        let program_expr = match &def.program_id {
3743            Some(pid) => rust_string_literal(pid),
3744            None => {
3745                needs_program_id = true;
3746                "PROGRAM_ID".to_string()
3747            }
3748        };
3749        let param_list = params
3750            .iter()
3751            .map(|(name, ty)| format!("{}: {}", name, ty))
3752            .collect::<Vec<_>>()
3753            .join(", ");
3754        let seeds_body = if seed_exprs.is_empty() {
3755            "            let seeds: Vec<Vec<u8>> = vec![];".to_string()
3756        } else {
3757            format!(
3758                "            let seeds: Vec<Vec<u8>> = vec![\n{}\n            ];",
3759                seed_exprs
3760                    .iter()
3761                    .map(|expr| format!("                {},", expr))
3762                    .collect::<Vec<_>>()
3763                    .join("\n")
3764            )
3765        };
3766        fns.push(format!(
3767            "        /// Derive the `{name}` PDA (returns the address and bump).\n        pub fn {fn_name}({params}) -> Result<(Pubkey, u8), InstructionError> {{\n{seeds}\n            derive_program_address(&seeds, {program})\n        }}",
3768            name = def.name,
3769            fn_name = fn_name,
3770            params = param_list,
3771            seeds = seeds_body,
3772            program = program_expr
3773        ));
3774    }
3775
3776    let mut imports = vec!["derive_program_address"];
3777    if needs_serialize {
3778        imports.push("serialize_seed_value");
3779    }
3780    imports.extend(["InstructionError", "Pubkey"]);
3781    imports.sort_unstable();
3782    let mut use_lines = format!(
3783        "        use arete_sdk::instruction::{{{}}};",
3784        imports.join(", ")
3785    );
3786    if needs_program_id {
3787        use_lines.push_str("\n\n        use super::PROGRAM_ID;");
3788    }
3789
3790    Some(format!(
3791        "    /// PDA derivation helpers for this program.\n    pub mod pdas {{\n{use_lines}\n\n{fns}\n    }}",
3792        use_lines = use_lines,
3793        fns = fns.join("\n\n")
3794    ))
3795}
3796
3797/// Release identity computed at generation time for one program, or the
3798/// reason the program's read layer is omitted.
3799type ProgramReadLayer = Result<(String, String), String>;
3800
3801/// Resolve the release identity (`PROGRAM_SPEC_HASH`, `PROGRAM_RELEASE_HASH`)
3802/// for one program from the stack's recorded program specs.
3803fn resolve_program_read_layer(
3804    program_specs: &[arete_hash::ProgramSpecV1],
3805    program_id: &str,
3806) -> ProgramReadLayer {
3807    let Some(spec) = program_specs
3808        .iter()
3809        .find(|spec| spec.program_id == program_id)
3810    else {
3811        return Err("no program specification was recorded for this program".to_string());
3812    };
3813    let spec_hash = spec
3814        .hash()
3815        .map_err(|error| format!("failed to compute the program spec hash ({error})"))?;
3816    let release_hash = spec
3817        .oss_release_hash()
3818        .map_err(|error| format!("failed to compute the release hash ({error})"))?;
3819    Ok((spec_hash.to_string(), release_hash.to_string()))
3820}
3821
3822/// Generate `programs.rs`: one module per program with typed instruction
3823/// builders, raw handlers, PDA helpers, and (when the stack records a program
3824/// spec for the program) release identity consts plus typed account readers.
3825/// Returns `None` when the stack declares no instructions.
3826#[allow(clippy::too_many_arguments)]
3827fn generate_stack_programs_rs(
3828    stack_name: &str,
3829    instructions: &[InstructionDef],
3830    idls: &[IdlSnapshot],
3831    pdas: &BTreeMap<String, BTreeMap<String, PdaDefinition>>,
3832    program_ids: &[String],
3833    program_specs: &[arete_hash::ProgramSpecV1],
3834    account_structs: &BTreeMap<String, String>,
3835    module_mode: bool,
3836) -> Option<ProgramsCodegen> {
3837    if instructions.is_empty() {
3838        return None;
3839    }
3840
3841    // Path to the generated types module from inside a `pub mod <program>`
3842    // block within programs.rs.
3843    let types_path = if module_mode {
3844        "super::super::types"
3845    } else {
3846        "crate::types"
3847    };
3848
3849    let default_program_id = program_ids.first().cloned().unwrap_or_default();
3850
3851    // Group instructions by resolved program id, preserving first-seen order.
3852    let mut groups: Vec<(String, Vec<&InstructionDef>)> = Vec::new();
3853    for instr in instructions {
3854        let pid = instr
3855            .program_id
3856            .clone()
3857            .unwrap_or_else(|| default_program_id.clone());
3858        match groups.iter_mut().find(|(existing, _)| *existing == pid) {
3859            Some((_, list)) => list.push(instr),
3860            None => groups.push((pid, vec![instr])),
3861        }
3862    }
3863
3864    let mut parser = RustDefinedTypes::new(idls);
3865    let mut used_module_names: HashSet<String> = HashSet::new();
3866    let mut module_blocks: Vec<String> = Vec::new();
3867    let mut modules: Vec<ProgramModule> = Vec::new();
3868
3869    for (index, (program_id, group)) in groups.iter().enumerate() {
3870        let idl = idls
3871            .iter()
3872            .find(|idl| idl.program_id.as_deref() == Some(program_id.as_str()));
3873        let raw_name = match idl {
3874            Some(idl) => idl.name.clone(),
3875            None if index == 0 => stack_name.to_string(),
3876            None => format!("program{}", index),
3877        };
3878        let mut module_name = rust_module_name(&raw_name);
3879        if module_name.is_empty() {
3880            module_name = format!("program{}", index);
3881        }
3882        while !used_module_names.insert(module_name.clone()) {
3883            module_name.push('_');
3884        }
3885        let struct_name = format!("{}Program", to_pascal_case(&module_name));
3886
3887        // PDA registry lookup: this program's group first, then any group.
3888        let own_pdas = idl.and_then(|idl| pdas.get(idl.name.as_str()));
3889        let mut pda_lookup: BTreeMap<&str, &PdaDefinition> = BTreeMap::new();
3890        if let Some(own) = own_pdas {
3891            for (name, def) in own {
3892                pda_lookup.insert(name.as_str(), def);
3893            }
3894        }
3895        for group_pdas in pdas.values() {
3896            for (name, def) in group_pdas {
3897                pda_lookup.entry(name.as_str()).or_insert(def);
3898            }
3899        }
3900
3901        let program_errors = idl
3902            .map(|idl| dedupe_errors_by_code(&idl.errors))
3903            .unwrap_or_default();
3904
3905        let mut needs = ProgramImports::default();
3906        let mut blocks: Vec<String> = Vec::new();
3907        let mut methods: Vec<String> = Vec::new();
3908        let mut skipped: Vec<(String, String)> = Vec::new();
3909        let mut uses_defined_types = false;
3910        for instr in group {
3911            let errors = if instr.errors.is_empty() {
3912                program_errors.clone()
3913            } else {
3914                dedupe_errors_by_code(&instr.errors)
3915            };
3916            match generate_rust_instruction_block(
3917                instr,
3918                &errors,
3919                &pda_lookup,
3920                &mut parser,
3921                &mut needs,
3922            ) {
3923                Ok(block) => {
3924                    blocks.push(block.code);
3925                    methods.push(block.method);
3926                    uses_defined_types |= block.uses_defined_types;
3927                }
3928                Err(reason) => skipped.push((instr.name.clone(), reason)),
3929            }
3930        }
3931
3932        // --- Program read layer: release identity + typed account readers. ---
3933        let read_layer = resolve_program_read_layer(program_specs, program_id);
3934        let mut reader_methods: Vec<String> = Vec::new();
3935        let mut reader_notes: Vec<String> = Vec::new();
3936        if read_layer.is_ok() {
3937            let mut used_method_names: HashSet<String> = group
3938                .iter()
3939                .map(|instr| to_snake_case(&instr.name))
3940                .collect();
3941            used_method_names.insert("from_builder".to_string());
3942            let accounts = idl.map(|idl| idl.accounts.as_slice()).unwrap_or_default();
3943            for account in accounts {
3944                let Some(struct_name) = account_structs.get(&account.name).or_else(|| {
3945                    account_structs
3946                        .iter()
3947                        .find(|(name, _)| name.eq_ignore_ascii_case(&account.name))
3948                        .map(|(_, emitted)| emitted)
3949                }) else {
3950                    // No generated struct for this account type; no reader.
3951                    continue;
3952                };
3953                let method_name = format!("{}_accounts", to_snake_case(&account.name));
3954                if !used_method_names.insert(method_name.clone()) {
3955                    reader_notes.push(format!(
3956                        "account reader for `{}` skipped: method name `{}` collides with an instruction builder",
3957                        account.name, method_name
3958                    ));
3959                    continue;
3960                }
3961                reader_methods.push(format!(
3962                    "        /// Typed reader for `{account}` accounts (release-addressed HTTP reads).\n        pub fn {method_name}(&self) -> Result<arete_sdk::AccountReader<{types_path}::{struct_name}>, arete_sdk::AreteError> {{\n            Ok(arete_sdk::AccountReader::new(\n                {account_literal},\n                std::sync::Arc::new(self.builder.account_transport({program_literal}, &read_descriptor())?),\n            ))\n        }}",
3963                    account = account.name,
3964                    method_name = method_name,
3965                    types_path = types_path,
3966                    struct_name = struct_name,
3967                    account_literal = rust_string_literal(&account.name),
3968                    program_literal = rust_string_literal(&raw_name),
3969                ));
3970            }
3971        }
3972
3973        let mut sections: Vec<String> = Vec::new();
3974        if !blocks.is_empty() {
3975            let mut imports = vec!["BuiltInstruction", "InstructionError", "InstructionHandler"];
3976            if needs.account_meta {
3977                imports.extend(["AccountMeta", "AccountResolution"]);
3978            }
3979            if needs.arg_schema {
3980                imports.extend(["ArgSchema", "ArgType"]);
3981            }
3982            if uses_defined_types {
3983                imports.extend(["ArgField", "EnumVariantDef", "EnumVariantKind"]);
3984            }
3985            if needs.error_metadata {
3986                imports.push("ErrorMetadata");
3987            }
3988            if needs.pda {
3989                imports.extend(["PdaConfig", "PdaSeed"]);
3990            }
3991            imports.sort_unstable();
3992            sections.push(format!(
3993                "    use arete_sdk::instruction::{{{}}};\n    use serde::Serialize;",
3994                imports.join(", ")
3995            ));
3996        }
3997        sections.push(format!(
3998            "    pub const PROGRAM_ID: &str = {};",
3999            rust_string_literal(program_id)
4000        ));
4001        if let Ok((spec_hash, release_hash)) = &read_layer {
4002            sections.push(format!(
4003                "    /// Content hash of the exact program specification captured at generation time.\n    pub const PROGRAM_SPEC_HASH: &str = {spec};\n\n    /// Release identity addressing hosted account reads for this program.\n    pub const PROGRAM_RELEASE_HASH: &str = {release};\n\n    /// Release-addressed read descriptor for this program (HTTP reads over\n    /// the client's HTTP base URL).\n    pub fn read_descriptor() -> arete_sdk::ProgramReadDescriptor {{\n        arete_sdk::ProgramReadDescriptor::LocalHttp {{\n            release: arete_sdk::ProgramReleaseReference {{\n                program_release_hash: PROGRAM_RELEASE_HASH.to_string(),\n                program_spec_hash: PROGRAM_SPEC_HASH.to_string(),\n            }},\n        }}\n    }}",
4004                spec = rust_string_literal(spec_hash),
4005                release = rust_string_literal(release_hash),
4006            ));
4007        }
4008        sections.extend(blocks);
4009        if let Some(pdas_module) = own_pdas.and_then(generate_rust_pdas_module) {
4010            sections.push(pdas_module);
4011        }
4012
4013        // Program accessor: carries the client's program runtime so account
4014        // readers can build release-addressed transports. Instruction
4015        // builders stay pure and are also available as free functions.
4016        let builder_field = if reader_methods.is_empty() {
4017            // No generated reader uses the runtime (yet); silence dead_code.
4018            "        #[allow(dead_code)]\n        builder: arete_sdk::ProgramBuilder,"
4019        } else {
4020            "        builder: arete_sdk::ProgramBuilder,"
4021        };
4022        let mut impl_methods: Vec<String> = vec![
4023            "        /// Construct from the connected client's program runtime.\n        pub fn from_builder(builder: arete_sdk::ProgramBuilder) -> Self {\n            Self { builder }\n        }"
4024                .to_string(),
4025        ];
4026        impl_methods.extend(methods);
4027        impl_methods.extend(reader_methods);
4028        let program_struct = format!(
4029            "    /// Program accessor exposed on the stack client's `programs` namespace.\n    #[derive(Clone)]\n    pub struct {struct_name} {{\n{builder_field}\n    }}\n\n    impl {struct_name} {{\n{impl_methods}\n    }}",
4030            struct_name = struct_name,
4031            builder_field = builder_field,
4032            impl_methods = impl_methods.join("\n\n")
4033        );
4034        sections.push(program_struct);
4035
4036        let mut doc = format!(
4037            "/// Program SDK for `{}` (program ID `{}`).\n",
4038            raw_name, program_id
4039        );
4040        if let Err(reason) = &read_layer {
4041            doc.push_str(&format!(
4042                "///\n/// Program read layer omitted: {}.\n",
4043                reason
4044            ));
4045        }
4046        if !reader_notes.is_empty() {
4047            doc.push_str("///\n");
4048            for note in &reader_notes {
4049                doc.push_str(&format!("/// {}\n", note));
4050            }
4051        }
4052        if !skipped.is_empty() {
4053            doc.push_str("///\n/// Skipped instructions (unsupported by instruction codegen):\n");
4054            for (name, reason) in &skipped {
4055                doc.push_str(&format!("/// - `{}`: {}\n", name, reason));
4056            }
4057        }
4058        module_blocks.push(format!(
4059            "{doc}pub mod {module_name} {{\n{body}\n}}",
4060            doc = doc,
4061            module_name = module_name,
4062            body = sections.join("\n\n")
4063        ));
4064        modules.push(ProgramModule {
4065            module_name,
4066            struct_name,
4067        });
4068    }
4069
4070    let code = format!(
4071        "//! Generated program SDK: typed instruction builders grouped per program.\n//!\n//! Instruction building is pure (no network access). Each program module\n//! exposes `PROGRAM_ID`, typed `*Params` structs, `fn <instruction>(params)`\n//! builders returning `BuiltInstruction`, raw `*_handler()` accessors, and a\n//! `pdas` module with PDA derivation helpers. Programs with a recorded\n//! program spec additionally expose `PROGRAM_SPEC_HASH` /\n//! `PROGRAM_RELEASE_HASH`, a `read_descriptor()` for release-addressed HTTP\n//! reads, and typed `*_accounts()` readers on the program accessor.\n\n{}\n",
4072        module_blocks.join("\n\n")
4073    );
4074
4075    Some(ProgramsCodegen { code, modules })
4076}
4077
4078fn to_kebab_case(s: &str) -> String {
4079    let mut result = String::new();
4080    for (i, c) in s.chars().enumerate() {
4081        if c.is_uppercase() {
4082            if i > 0 {
4083                result.push('-');
4084            }
4085            result.push(c.to_lowercase().next().unwrap());
4086        } else {
4087            result.push(c);
4088        }
4089    }
4090    result
4091}
4092
4093fn to_pascal_case(s: &str) -> String {
4094    s.split(['_', '-', '.'])
4095        .map(|word| {
4096            let mut chars = word.chars();
4097            match chars.next() {
4098                None => String::new(),
4099                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4100            }
4101        })
4102        .collect()
4103}
4104
4105fn to_snake_case(s: &str) -> String {
4106    let mut result = String::new();
4107    let mut separator = false;
4108    for ch in s.chars() {
4109        if ch.is_ascii_alphanumeric() {
4110            if separator && !result.is_empty() {
4111                result.push('_');
4112            }
4113            separator = false;
4114            if ch.is_ascii_uppercase() {
4115                if !result.is_empty() && !result.ends_with('_') {
4116                    result.push('_');
4117                }
4118                result.push(ch.to_ascii_lowercase());
4119            } else {
4120                result.push(ch.to_ascii_lowercase());
4121            }
4122        } else {
4123            separator = true;
4124        }
4125    }
4126    if result
4127        .chars()
4128        .next()
4129        .is_some_and(|character| character.is_ascii_digit())
4130    {
4131        result.insert_str(0, "value_");
4132    }
4133    if is_rust_keyword(&result) {
4134        result.push('_');
4135    }
4136    result
4137}
4138
4139/// Derive a valid Rust module name from an arbitrary alias or file stem
4140/// (lowercased, non-alphanumerics collapsed to `_`, keywords and leading
4141/// digits escaped). Shared with the CLI so staged devex extension files wire
4142/// up under the same stems the composition generator would use.
4143pub fn rust_module_name(value: &str) -> String {
4144    let mut output = String::new();
4145    let mut separator = false;
4146    for character in value.chars() {
4147        if character.is_ascii_alphanumeric() {
4148            if separator && !output.is_empty() {
4149                output.push('_');
4150            }
4151            separator = false;
4152            output.push(character.to_ascii_lowercase());
4153        } else {
4154            separator = true;
4155        }
4156    }
4157    if output
4158        .chars()
4159        .next()
4160        .is_some_and(|character| character.is_ascii_digit())
4161    {
4162        output.insert_str(0, "live_");
4163    }
4164    if is_rust_keyword(&output) {
4165        output.push_str("_live");
4166    }
4167    output
4168}
4169
4170fn is_rust_keyword(value: &str) -> bool {
4171    matches!(
4172        value,
4173        "as" | "async"
4174            | "await"
4175            | "break"
4176            | "const"
4177            | "continue"
4178            | "crate"
4179            | "dyn"
4180            | "else"
4181            | "enum"
4182            | "extern"
4183            | "false"
4184            | "fn"
4185            | "for"
4186            | "if"
4187            | "impl"
4188            | "in"
4189            | "let"
4190            | "loop"
4191            | "match"
4192            | "mod"
4193            | "move"
4194            | "mut"
4195            | "pub"
4196            | "ref"
4197            | "return"
4198            | "self"
4199            | "Self"
4200            | "static"
4201            | "struct"
4202            | "super"
4203            | "trait"
4204            | "true"
4205            | "type"
4206            | "union"
4207            | "unsafe"
4208            | "use"
4209            | "where"
4210            | "while"
4211            | "abstract"
4212            | "become"
4213            | "box"
4214            | "do"
4215            | "final"
4216            | "macro"
4217            | "override"
4218            | "priv"
4219            | "typeof"
4220            | "unsized"
4221            | "virtual"
4222            | "yield"
4223            | "try"
4224    )
4225}