Skip to main content

arete_idl/
snapshot.rs

1//! Snapshot type definitions
2
3use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
4
5use crate::types::{IdlAmountHint, SteelDiscriminant};
6
7/// Version of the semantic IDL normalization performed by this crate.
8///
9/// Incrementing this value is a protocol change: it changes the canonical
10/// `IdlSnapshotV1` input and all identities that contain it.
11pub const IDL_NORMALIZATION_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, Serialize)]
14pub struct IdlSnapshot {
15    pub name: String,
16    #[serde(default, skip_serializing_if = "Option::is_none", alias = "address")]
17    pub program_id: Option<String>,
18    pub version: String,
19    pub accounts: Vec<IdlAccountSnapshot>,
20    pub instructions: Vec<IdlInstructionSnapshot>,
21    #[serde(default)]
22    pub types: Vec<IdlTypeDefSnapshot>,
23    #[serde(default)]
24    pub events: Vec<IdlEventSnapshot>,
25    #[serde(default)]
26    pub errors: Vec<IdlErrorSnapshot>,
27    pub discriminant_size: usize,
28}
29
30/// Canonical, versioned input for normalized IDL identities.
31///
32/// The legacy `IdlSnapshot` wire shape remains unchanged for existing ASTs.
33/// This wrapper adds the normalization version only to new protocol inputs.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", try_from = "IdlSnapshotV1Wire")]
36pub struct IdlSnapshotV1 {
37    pub normalization_version: u32,
38    #[serde(flatten)]
39    pub snapshot: IdlSnapshot,
40}
41
42#[derive(Deserialize)]
43#[serde(rename_all = "camelCase")]
44struct IdlSnapshotV1Wire {
45    normalization_version: u32,
46    #[serde(flatten)]
47    snapshot: IdlSnapshot,
48}
49
50impl TryFrom<IdlSnapshotV1Wire> for IdlSnapshotV1 {
51    type Error = String;
52
53    fn try_from(value: IdlSnapshotV1Wire) -> Result<Self, Self::Error> {
54        if value.normalization_version != IDL_NORMALIZATION_VERSION {
55            return Err(format!(
56                "unsupported IDL normalization version {}; expected {}",
57                value.normalization_version, IDL_NORMALIZATION_VERSION
58            ));
59        }
60        Ok(Self {
61            normalization_version: value.normalization_version,
62            snapshot: value.snapshot,
63        })
64    }
65}
66
67impl IdlSnapshotV1 {
68    pub fn new(snapshot: IdlSnapshot) -> Self {
69        Self {
70            normalization_version: IDL_NORMALIZATION_VERSION,
71            snapshot,
72        }
73    }
74
75    pub fn into_legacy_snapshot(self) -> IdlSnapshot {
76        self.snapshot
77    }
78}
79
80impl<'de> Deserialize<'de> for IdlSnapshot {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: Deserializer<'de>,
84    {
85        // First deserialize to a generic Value to inspect instructions
86        let value = serde_json::Value::deserialize(deserializer)?;
87
88        // Check if any instruction has discriminant (Steel-style) vs discriminator (Anchor-style)
89        let discriminant_size = value
90            .get("instructions")
91            .and_then(|instrs| instrs.as_array())
92            .map(|instrs| {
93                if instrs.is_empty() {
94                    return false;
95                }
96                instrs.iter().all(|ix| {
97                    let discriminator = ix.get("discriminator");
98                    let disc_len = discriminator
99                        .and_then(|d| d.as_array())
100                        .map(|a| a.len())
101                        .unwrap_or(0);
102
103                    // Treat discriminant as present only if the value is non-null.
104                    // ix.get("discriminant").is_some() returns true even for `null`,
105                    // which causes misclassification when the AST serializer writes
106                    // `discriminant: null` explicitly (as the ore AST does).
107                    let has_discriminant = ix
108                        .get("discriminant")
109                        .map(|v| !v.is_null())
110                        .unwrap_or(false);
111                    let has_discriminator = discriminator
112                        .map(|d| {
113                            !d.is_null() && d.as_array().map(|a| !a.is_empty()).unwrap_or(true)
114                        })
115                        .unwrap_or(false);
116
117                    // Steel-style variant 1: explicit discriminant object, no discriminator array
118                    let is_steel_discriminant = has_discriminant && !has_discriminator;
119
120                    // Steel-style variant 2: discriminator is stored as a 1-byte array with no
121                    // discriminant value. This happens when the AST serializer flattens the
122                    // Steel u8 discriminant directly into the discriminator field.
123                    let is_steel_short_discriminator = !has_discriminant && disc_len == 1;
124
125                    is_steel_discriminant || is_steel_short_discriminator
126                })
127            })
128            .map(|is_steel| if is_steel { 1 } else { 8 })
129            .unwrap_or(8); // Default to 8 if no instructions
130
131        // Now deserialize the full struct
132        let mut intermediate: IdlSnapshotIntermediate = serde_json::from_value(value)
133            .map_err(|e| DeError::custom(format!("Failed to deserialize IDL: {}", e)))?;
134        // Only use the heuristic if discriminant_size wasn't already present in the JSON
135        // (discriminant_size = 0 means it was absent / defaulted).
136        if intermediate.discriminant_size == 0 {
137            intermediate.discriminant_size = discriminant_size;
138        }
139
140        Ok(IdlSnapshot {
141            name: intermediate.name,
142            program_id: intermediate.program_id,
143            version: intermediate.version,
144            accounts: intermediate.accounts,
145            instructions: intermediate.instructions,
146            types: intermediate.types,
147            events: intermediate.events,
148            errors: intermediate.errors,
149            discriminant_size: intermediate.discriminant_size,
150        })
151    }
152}
153
154// Intermediate struct for deserialization
155#[derive(Debug, Clone, Deserialize)]
156struct IdlSnapshotIntermediate {
157    pub name: String,
158    #[serde(default, alias = "address")]
159    pub program_id: Option<String>,
160    pub version: String,
161    pub accounts: Vec<IdlAccountSnapshot>,
162    pub instructions: Vec<IdlInstructionSnapshot>,
163    #[serde(default)]
164    pub types: Vec<IdlTypeDefSnapshot>,
165    #[serde(default)]
166    pub events: Vec<IdlEventSnapshot>,
167    #[serde(default)]
168    pub errors: Vec<IdlErrorSnapshot>,
169    #[serde(default)]
170    pub discriminant_size: usize,
171}
172
173#[derive(Debug, Clone, Serialize)]
174pub struct IdlAccountSnapshot {
175    pub name: String,
176    pub discriminator: Vec<u8>,
177    pub docs: Vec<String>,
178    pub serialization: Option<IdlSerializationSnapshot>,
179    /// Account fields - populated from inline type definition
180    pub fields: Vec<IdlFieldSnapshot>,
181    /// Inline type definition (for Steel format with type.fields structure)
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub type_def: Option<IdlInlineTypeDef>,
184}
185
186// Intermediate struct for deserialization
187#[derive(Deserialize)]
188struct IdlAccountSnapshotIntermediate {
189    pub name: String,
190    pub discriminator: Vec<u8>,
191    #[serde(default)]
192    pub docs: Vec<String>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub serialization: Option<IdlSerializationSnapshot>,
195    #[serde(default)]
196    pub fields: Vec<IdlFieldSnapshot>,
197    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
198    pub type_def: Option<IdlInlineTypeDef>,
199}
200
201impl<'de> Deserialize<'de> for IdlAccountSnapshot {
202    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203    where
204        D: Deserializer<'de>,
205    {
206        let intermediate = IdlAccountSnapshotIntermediate::deserialize(deserializer)?;
207
208        // Normalize fields: if empty but type_def has fields, use those
209        let fields = if intermediate.fields.is_empty() {
210            if let Some(type_def) = intermediate.type_def.as_ref() {
211                type_def.fields.clone()
212            } else {
213                intermediate.fields
214            }
215        } else {
216            intermediate.fields
217        };
218
219        Ok(IdlAccountSnapshot {
220            name: intermediate.name,
221            discriminator: intermediate.discriminator,
222            docs: intermediate.docs,
223            serialization: intermediate.serialization,
224            fields,
225            type_def: intermediate.type_def,
226        })
227    }
228}
229
230/// Inline type definition for account fields (Steel format)
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct IdlInlineTypeDef {
233    pub kind: String,
234    pub fields: Vec<IdlFieldSnapshot>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct IdlInstructionSnapshot {
239    pub name: String,
240    #[serde(default)]
241    pub discriminator: Vec<u8>,
242    #[serde(default)]
243    pub discriminant: Option<SteelDiscriminant>,
244    #[serde(default)]
245    pub docs: Vec<String>,
246    pub accounts: Vec<IdlInstructionAccountSnapshot>,
247    pub args: Vec<IdlFieldSnapshot>,
248}
249
250impl IdlInstructionSnapshot {
251    /// Get the computed 8-byte discriminator.
252    /// Returns the explicit discriminator if present, otherwise computes from discriminant.
253    pub fn get_discriminator(&self) -> Vec<u8> {
254        if !self.discriminator.is_empty() {
255            return self.discriminator.clone();
256        }
257
258        if let Some(disc) = &self.discriminant {
259            match u8::try_from(disc.value) {
260                Ok(value) => return vec![value],
261                Err(_) => {
262                    tracing::warn!(
263                        instruction = %self.name,
264                        value = disc.value,
265                        "Steel discriminant exceeds u8::MAX; falling back to Anchor hash"
266                    );
267                }
268            }
269        }
270
271        crate::discriminator::anchor_discriminator(&format!(
272            "global:{}",
273            crate::utils::to_snake_case(&self.name)
274        ))
275    }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct IdlInstructionAccountSnapshot {
280    pub name: String,
281    #[serde(default)]
282    pub writable: bool,
283    #[serde(default)]
284    pub signer: bool,
285    #[serde(default)]
286    pub optional: bool,
287    #[serde(default)]
288    pub address: Option<String>,
289    #[serde(default)]
290    pub docs: Vec<String>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct IdlFieldSnapshot {
295    pub name: String,
296    #[serde(rename = "type")]
297    pub type_: IdlTypeSnapshot,
298    #[serde(
299        default,
300        skip_serializing_if = "Option::is_none",
301        rename = "amountHint"
302    )]
303    pub amount_hint: Option<IdlAmountHint>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(untagged)]
308pub enum IdlTypeSnapshot {
309    Simple(String),
310    Array(IdlArrayTypeSnapshot),
311    Option(IdlOptionTypeSnapshot),
312    Vec(IdlVecTypeSnapshot),
313    HashMap(IdlHashMapTypeSnapshot),
314    Defined(IdlDefinedTypeSnapshot),
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct IdlHashMapTypeSnapshot {
319    #[serde(rename = "hashMap", deserialize_with = "deserialize_hash_map")]
320    pub hash_map: (Box<IdlTypeSnapshot>, Box<IdlTypeSnapshot>),
321}
322
323fn deserialize_hash_map<'de, D>(
324    deserializer: D,
325) -> Result<(Box<IdlTypeSnapshot>, Box<IdlTypeSnapshot>), D::Error>
326where
327    D: Deserializer<'de>,
328{
329    use serde::de::Error;
330    let values: Vec<IdlTypeSnapshot> = Vec::deserialize(deserializer)?;
331    if values.len() != 2 {
332        return Err(D::Error::custom("hashMap must have exactly 2 elements"));
333    }
334    let mut iter = values.into_iter();
335    Ok((
336        Box::new(iter.next().expect("length checked")),
337        Box::new(iter.next().expect("length checked")),
338    ))
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct IdlArrayTypeSnapshot {
343    pub array: Vec<IdlArrayElementSnapshot>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347#[serde(untagged)]
348pub enum IdlArrayElementSnapshot {
349    Type(IdlTypeSnapshot),
350    TypeName(String),
351    Size(u32),
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct IdlOptionTypeSnapshot {
356    pub option: Box<IdlTypeSnapshot>,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360pub struct IdlVecTypeSnapshot {
361    pub vec: Box<IdlTypeSnapshot>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct IdlDefinedTypeSnapshot {
366    pub defined: IdlDefinedInnerSnapshot,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370#[serde(untagged)]
371pub enum IdlDefinedInnerSnapshot {
372    Named { name: String },
373    Simple(String),
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377#[serde(rename_all = "lowercase")]
378pub enum IdlSerializationSnapshot {
379    Borsh,
380    Bytemuck,
381    #[serde(alias = "bytemuckunsafe")]
382    BytemuckUnsafe,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct IdlTypeDefSnapshot {
387    pub name: String,
388    #[serde(default)]
389    pub docs: Vec<String>,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub serialization: Option<IdlSerializationSnapshot>,
392    #[serde(rename = "type")]
393    pub type_def: IdlTypeDefKindSnapshot,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[serde(untagged)]
398pub enum IdlTypeDefKindSnapshot {
399    Struct {
400        kind: String,
401        fields: Vec<IdlFieldSnapshot>,
402    },
403    TupleStruct {
404        kind: String,
405        fields: Vec<IdlTypeSnapshot>,
406    },
407    Enum {
408        kind: String,
409        variants: Vec<IdlEnumVariantSnapshot>,
410    },
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct IdlEnumVariantSnapshot {
415    pub name: String,
416    /// Variant payload, when the variant carries data. Skipped when empty so
417    /// fieldless variants serialize byte-identically to older snapshots.
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub fields: Vec<IdlEnumVariantFieldSnapshot>,
420}
421
422/// One field of a data-carrying enum variant (named struct field or bare
423/// tuple element).
424#[derive(Debug, Clone, Serialize, Deserialize)]
425#[serde(untagged)]
426pub enum IdlEnumVariantFieldSnapshot {
427    Named(IdlFieldSnapshot),
428    Tuple(IdlTypeSnapshot),
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct IdlEventSnapshot {
433    pub name: String,
434    pub discriminator: Vec<u8>,
435    #[serde(default)]
436    pub docs: Vec<String>,
437    #[serde(default)]
438    pub fields: Vec<IdlFieldSnapshot>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
442pub struct IdlErrorSnapshot {
443    pub code: u32,
444    pub name: String,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub msg: Option<String>,
447}
448
449/// Normalize a parsed IDL into the stable snapshot used by existing ASTs.
450pub fn normalize_idl_snapshot(idl: &crate::types::IdlSpec) -> IdlSnapshot {
451    use crate::types::IdlTypeDefKind;
452
453    let mut types: Vec<IdlTypeDefSnapshot> = idl
454        .types
455        .iter()
456        .map(|typedef| IdlTypeDefSnapshot {
457            name: typedef.name.clone(),
458            docs: typedef.docs.clone(),
459            serialization: typedef.serialization.as_ref().map(snapshot_serialization),
460            type_def: snapshot_type_def(&typedef.type_def),
461        })
462        .collect();
463
464    for account in &idl.accounts {
465        if types.iter().any(|typedef| typedef.name == account.name) {
466            continue;
467        }
468        if let Some(type_def) = &account.type_def {
469            types.push(IdlTypeDefSnapshot {
470                name: account.name.clone(),
471                docs: account.docs.clone(),
472                serialization: None,
473                type_def: snapshot_type_def(type_def),
474            });
475        }
476    }
477
478    let uses_steel_discriminant = idl.instructions.iter().any(|instruction| {
479        instruction.discriminant.is_some() && instruction.discriminator.is_empty()
480    });
481    let discriminant_size = if uses_steel_discriminant { 1 } else { 8 };
482    let program_id = idl.address.clone().or_else(|| {
483        idl.metadata
484            .as_ref()
485            .and_then(|metadata| metadata.address.clone())
486    });
487
488    IdlSnapshot {
489        name: idl.get_name().to_string(),
490        program_id,
491        version: idl.get_version().to_string(),
492        accounts: idl
493            .accounts
494            .iter()
495            .map(|account| {
496                let serialization = idl
497                    .types
498                    .iter()
499                    .find(|typedef| typedef.name == account.name)
500                    .and_then(|typedef| typedef.serialization.as_ref())
501                    .map(snapshot_serialization);
502                let fields =
503                    account
504                        .type_def
505                        .as_ref()
506                        .map_or_else(Vec::new, |type_def| match type_def {
507                            IdlTypeDefKind::Struct { fields, .. } => {
508                                fields.iter().map(snapshot_field).collect()
509                            }
510                            _ => Vec::new(),
511                        });
512
513                IdlAccountSnapshot {
514                    name: account.name.clone(),
515                    discriminator: account.get_discriminator(),
516                    docs: account.docs.clone(),
517                    serialization,
518                    fields,
519                    type_def: None,
520                }
521            })
522            .collect(),
523        instructions: idl
524            .instructions
525            .iter()
526            .map(|instruction| IdlInstructionSnapshot {
527                name: instruction.name.clone(),
528                discriminator: instruction.get_discriminator(),
529                discriminant: instruction.discriminant.clone(),
530                docs: instruction.docs.clone(),
531                accounts: instruction
532                    .flattened_accounts()
533                    .iter()
534                    .map(|account| IdlInstructionAccountSnapshot {
535                        name: account.name.clone(),
536                        writable: account.is_mut,
537                        signer: account.is_signer,
538                        optional: account.optional,
539                        address: account.address.clone(),
540                        docs: account.docs.clone(),
541                    })
542                    .collect(),
543                args: instruction.args.iter().map(snapshot_field).collect(),
544            })
545            .collect(),
546        types,
547        events: idl
548            .events
549            .iter()
550            .map(|event| IdlEventSnapshot {
551                name: event.name.clone(),
552                discriminator: event.get_discriminator(),
553                docs: event.docs.clone(),
554                fields: event.fields.iter().map(snapshot_field).collect(),
555            })
556            .collect(),
557        errors: idl
558            .errors
559            .iter()
560            .map(|error| IdlErrorSnapshot {
561                code: error.code,
562                name: error.name.clone(),
563                msg: error.msg.clone(),
564            })
565            .collect(),
566        discriminant_size,
567    }
568}
569
570/// Normalize a parsed IDL into the versioned input used by authoritative hashes.
571pub fn normalize_idl_snapshot_v1(idl: &crate::types::IdlSpec) -> IdlSnapshotV1 {
572    IdlSnapshotV1::new(normalize_idl_snapshot(idl))
573}
574
575fn snapshot_serialization(
576    serialization: &crate::types::IdlSerialization,
577) -> IdlSerializationSnapshot {
578    match serialization {
579        crate::types::IdlSerialization::Borsh => IdlSerializationSnapshot::Borsh,
580        crate::types::IdlSerialization::Bytemuck => IdlSerializationSnapshot::Bytemuck,
581        crate::types::IdlSerialization::BytemuckUnsafe => IdlSerializationSnapshot::BytemuckUnsafe,
582    }
583}
584
585fn snapshot_field(field: &crate::types::IdlField) -> IdlFieldSnapshot {
586    IdlFieldSnapshot {
587        name: field.name.clone(),
588        type_: snapshot_type(&field.type_),
589        amount_hint: field.amount_hint.clone(),
590    }
591}
592
593fn snapshot_type_def(type_def: &crate::types::IdlTypeDefKind) -> IdlTypeDefKindSnapshot {
594    match type_def {
595        crate::types::IdlTypeDefKind::Struct { kind, fields } => IdlTypeDefKindSnapshot::Struct {
596            kind: kind.clone(),
597            fields: fields.iter().map(snapshot_field).collect(),
598        },
599        crate::types::IdlTypeDefKind::TupleStruct { kind, fields } => {
600            IdlTypeDefKindSnapshot::TupleStruct {
601                kind: kind.clone(),
602                fields: fields.iter().map(snapshot_type).collect(),
603            }
604        }
605        crate::types::IdlTypeDefKind::Enum { kind, variants } => IdlTypeDefKindSnapshot::Enum {
606            kind: kind.clone(),
607            variants: variants.iter().map(snapshot_enum_variant).collect(),
608        },
609    }
610}
611
612fn snapshot_enum_variant(variant: &crate::types::IdlEnumVariant) -> IdlEnumVariantSnapshot {
613    IdlEnumVariantSnapshot {
614        name: variant.name.clone(),
615        fields: variant
616            .fields
617            .iter()
618            .map(|field| match field {
619                crate::types::IdlEnumVariantField::Named(named) => {
620                    IdlEnumVariantFieldSnapshot::Named(snapshot_field(named))
621                }
622                crate::types::IdlEnumVariantField::Tuple(tuple) => {
623                    IdlEnumVariantFieldSnapshot::Tuple(snapshot_type(tuple))
624                }
625            })
626            .collect(),
627    }
628}
629
630fn snapshot_type(idl_type: &crate::types::IdlType) -> IdlTypeSnapshot {
631    match idl_type {
632        crate::types::IdlType::Simple(simple) => IdlTypeSnapshot::Simple(simple.clone()),
633        crate::types::IdlType::Array(array) => IdlTypeSnapshot::Array(IdlArrayTypeSnapshot {
634            array: array
635                .array
636                .iter()
637                .map(|element| match element {
638                    crate::types::IdlTypeArrayElement::Nested(ty) => {
639                        IdlArrayElementSnapshot::Type(snapshot_type(ty))
640                    }
641                    crate::types::IdlTypeArrayElement::Type(type_name) => {
642                        IdlArrayElementSnapshot::TypeName(type_name.clone())
643                    }
644                    crate::types::IdlTypeArrayElement::Size(size) => {
645                        IdlArrayElementSnapshot::Size(*size)
646                    }
647                })
648                .collect(),
649        }),
650        crate::types::IdlType::Option(option) => IdlTypeSnapshot::Option(IdlOptionTypeSnapshot {
651            option: Box::new(snapshot_type(&option.option)),
652        }),
653        crate::types::IdlType::Vec(vec_type) => IdlTypeSnapshot::Vec(IdlVecTypeSnapshot {
654            vec: Box::new(snapshot_type(&vec_type.vec)),
655        }),
656        crate::types::IdlType::Defined(defined) => {
657            IdlTypeSnapshot::Defined(IdlDefinedTypeSnapshot {
658                defined: match &defined.defined {
659                    crate::types::IdlTypeDefinedInner::Named { name } => {
660                        IdlDefinedInnerSnapshot::Named { name: name.clone() }
661                    }
662                    crate::types::IdlTypeDefinedInner::Simple(simple) => {
663                        IdlDefinedInnerSnapshot::Simple(simple.clone())
664                    }
665                },
666            })
667        }
668        crate::types::IdlType::HashMap(hash_map) => {
669            IdlTypeSnapshot::HashMap(IdlHashMapTypeSnapshot {
670                hash_map: (
671                    Box::new(snapshot_type(&hash_map.hash_map.0)),
672                    Box::new(snapshot_type(&hash_map.hash_map.1)),
673                ),
674            })
675        }
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn test_fieldless_enum_variant_wire_format_unchanged() {
685        // Fieldless variants must serialize WITHOUT a `fields` key so existing
686        // stack.json files (and the CI regenerate-diff) are unaffected by the
687        // fielded-enum extension.
688        let fieldless = IdlEnumVariantSnapshot {
689            name: "Active".to_string(),
690            fields: vec![],
691        };
692        assert_eq!(
693            serde_json::to_string(&fieldless).unwrap(),
694            r#"{"name":"Active"}"#
695        );
696
697        // Old-format JSON (no `fields`) still deserializes.
698        let parsed: IdlEnumVariantSnapshot = serde_json::from_str(r#"{"name":"Active"}"#).unwrap();
699        assert!(parsed.fields.is_empty());
700
701        // Fielded variants round-trip through both named and tuple shapes,
702        // and the untagged IdlTypeDefKindSnapshot still classifies as Enum.
703        let fielded = IdlTypeDefKindSnapshot::Enum {
704            kind: "enum".to_string(),
705            variants: vec![IdlEnumVariantSnapshot {
706                name: "Sunset".to_string(),
707                fields: vec![
708                    IdlEnumVariantFieldSnapshot::Named(IdlFieldSnapshot {
709                        name: "endTs".to_string(),
710                        type_: IdlTypeSnapshot::Simple("i64".to_string()),
711                        amount_hint: None,
712                    }),
713                    IdlEnumVariantFieldSnapshot::Tuple(IdlTypeSnapshot::Simple("u8".to_string())),
714                ],
715            }],
716        };
717        let json = serde_json::to_string(&fielded).unwrap();
718        let parsed: IdlTypeDefKindSnapshot = serde_json::from_str(&json).unwrap();
719        match parsed {
720            IdlTypeDefKindSnapshot::Enum { variants, .. } => {
721                assert_eq!(variants[0].fields.len(), 2);
722                assert!(matches!(
723                    variants[0].fields[0],
724                    IdlEnumVariantFieldSnapshot::Named(_)
725                ));
726                assert!(matches!(
727                    variants[0].fields[1],
728                    IdlEnumVariantFieldSnapshot::Tuple(_)
729                ));
730            }
731            other => panic!("untagged round-trip misclassified enum as {:?}", other),
732        }
733    }
734
735    #[test]
736    fn test_snapshot_serde() {
737        let snapshot = IdlSnapshot {
738            name: "test_program".to_string(),
739            program_id: Some("11111111111111111111111111111111".to_string()),
740            version: "0.1.0".to_string(),
741            accounts: vec![IdlAccountSnapshot {
742                name: "ExampleAccount".to_string(),
743                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
744                docs: vec!["Example account".to_string()],
745                serialization: Some(IdlSerializationSnapshot::Borsh),
746                fields: vec![],
747                type_def: None,
748            }],
749            instructions: vec![IdlInstructionSnapshot {
750                name: "example_instruction".to_string(),
751                discriminator: vec![8, 7, 6, 5, 4, 3, 2, 1],
752                discriminant: None,
753                docs: vec!["Example instruction".to_string()],
754                accounts: vec![IdlInstructionAccountSnapshot {
755                    name: "payer".to_string(),
756                    writable: true,
757                    signer: true,
758                    optional: false,
759                    address: None,
760                    docs: vec![],
761                }],
762                args: vec![IdlFieldSnapshot {
763                    name: "amount".to_string(),
764                    type_: IdlTypeSnapshot::HashMap(IdlHashMapTypeSnapshot {
765                        hash_map: (
766                            Box::new(IdlTypeSnapshot::Simple("u64".to_string())),
767                            Box::new(IdlTypeSnapshot::Simple("string".to_string())),
768                        ),
769                    }),
770                    amount_hint: None,
771                }],
772            }],
773            types: vec![IdlTypeDefSnapshot {
774                name: "ExampleType".to_string(),
775                docs: vec![],
776                serialization: None,
777                type_def: IdlTypeDefKindSnapshot::Struct {
778                    kind: "struct".to_string(),
779                    fields: vec![IdlFieldSnapshot {
780                        name: "value".to_string(),
781                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
782                        amount_hint: None,
783                    }],
784                },
785            }],
786            events: vec![IdlEventSnapshot {
787                name: "ExampleEvent".to_string(),
788                discriminator: vec![0, 0, 0, 0, 0, 0, 0, 1],
789                docs: vec![],
790                fields: vec![],
791            }],
792            errors: vec![IdlErrorSnapshot {
793                code: 6000,
794                name: "ExampleError".to_string(),
795                msg: Some("example".to_string()),
796            }],
797            discriminant_size: 8,
798        };
799
800        let serialized = serde_json::to_value(&snapshot).expect("serialize snapshot");
801        let deserialized: IdlSnapshot =
802            serde_json::from_value(serialized.clone()).expect("deserialize snapshot");
803        let round_trip = serde_json::to_value(&deserialized).expect("re-serialize snapshot");
804
805        assert_eq!(serialized, round_trip);
806        assert_eq!(deserialized.name, "test_program");
807    }
808
809    #[test]
810    fn test_hashmap_compat() {
811        let json = r#"{"hashMap":["u64","string"]}"#;
812        let parsed: IdlHashMapTypeSnapshot =
813            serde_json::from_str(json).expect("deserialize hashMap");
814
815        assert!(matches!(
816            parsed.hash_map.0.as_ref(),
817            IdlTypeSnapshot::Simple(value) if value == "u64"
818        ));
819        assert!(matches!(
820            parsed.hash_map.1.as_ref(),
821            IdlTypeSnapshot::Simple(value) if value == "string"
822        ));
823    }
824
825    #[test]
826    fn idl_snapshot_v1_rejects_unknown_normalization_versions() {
827        let value = serde_json::json!({
828            "normalizationVersion": 2,
829            "name": "demo",
830            "program_id": "11111111111111111111111111111111",
831            "version": "1.0.0",
832            "accounts": [],
833            "instructions": [],
834            "types": [],
835            "events": [],
836            "errors": [],
837            "discriminant_size": 8
838        });
839
840        let error = serde_json::from_value::<IdlSnapshotV1>(value)
841            .expect_err("unknown normalization version must fail");
842        assert!(error
843            .to_string()
844            .contains("unsupported IDL normalization version 2"));
845    }
846}