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#[derive(Debug, Clone, Serialize)]
8pub struct IdlSnapshot {
9    pub name: String,
10    #[serde(default, skip_serializing_if = "Option::is_none", alias = "address")]
11    pub program_id: Option<String>,
12    pub version: String,
13    pub accounts: Vec<IdlAccountSnapshot>,
14    pub instructions: Vec<IdlInstructionSnapshot>,
15    #[serde(default)]
16    pub types: Vec<IdlTypeDefSnapshot>,
17    #[serde(default)]
18    pub events: Vec<IdlEventSnapshot>,
19    #[serde(default)]
20    pub errors: Vec<IdlErrorSnapshot>,
21    pub discriminant_size: usize,
22}
23
24impl<'de> Deserialize<'de> for IdlSnapshot {
25    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
26    where
27        D: Deserializer<'de>,
28    {
29        // First deserialize to a generic Value to inspect instructions
30        let value = serde_json::Value::deserialize(deserializer)?;
31
32        // Check if any instruction has discriminant (Steel-style) vs discriminator (Anchor-style)
33        let discriminant_size = value
34            .get("instructions")
35            .and_then(|instrs| instrs.as_array())
36            .map(|instrs| {
37                if instrs.is_empty() {
38                    return false;
39                }
40                instrs.iter().all(|ix| {
41                    let discriminator = ix.get("discriminator");
42                    let disc_len = discriminator
43                        .and_then(|d| d.as_array())
44                        .map(|a| a.len())
45                        .unwrap_or(0);
46
47                    // Treat discriminant as present only if the value is non-null.
48                    // ix.get("discriminant").is_some() returns true even for `null`,
49                    // which causes misclassification when the AST serializer writes
50                    // `discriminant: null` explicitly (as the ore AST does).
51                    let has_discriminant = ix
52                        .get("discriminant")
53                        .map(|v| !v.is_null())
54                        .unwrap_or(false);
55                    let has_discriminator = discriminator
56                        .map(|d| {
57                            !d.is_null() && d.as_array().map(|a| !a.is_empty()).unwrap_or(true)
58                        })
59                        .unwrap_or(false);
60
61                    // Steel-style variant 1: explicit discriminant object, no discriminator array
62                    let is_steel_discriminant = has_discriminant && !has_discriminator;
63
64                    // Steel-style variant 2: discriminator is stored as a 1-byte array with no
65                    // discriminant value. This happens when the AST serializer flattens the
66                    // Steel u8 discriminant directly into the discriminator field.
67                    let is_steel_short_discriminator = !has_discriminant && disc_len == 1;
68
69                    is_steel_discriminant || is_steel_short_discriminator
70                })
71            })
72            .map(|is_steel| if is_steel { 1 } else { 8 })
73            .unwrap_or(8); // Default to 8 if no instructions
74
75        // Now deserialize the full struct
76        let mut intermediate: IdlSnapshotIntermediate = serde_json::from_value(value)
77            .map_err(|e| DeError::custom(format!("Failed to deserialize IDL: {}", e)))?;
78        // Only use the heuristic if discriminant_size wasn't already present in the JSON
79        // (discriminant_size = 0 means it was absent / defaulted).
80        if intermediate.discriminant_size == 0 {
81            intermediate.discriminant_size = discriminant_size;
82        }
83
84        Ok(IdlSnapshot {
85            name: intermediate.name,
86            program_id: intermediate.program_id,
87            version: intermediate.version,
88            accounts: intermediate.accounts,
89            instructions: intermediate.instructions,
90            types: intermediate.types,
91            events: intermediate.events,
92            errors: intermediate.errors,
93            discriminant_size: intermediate.discriminant_size,
94        })
95    }
96}
97
98// Intermediate struct for deserialization
99#[derive(Debug, Clone, Deserialize)]
100struct IdlSnapshotIntermediate {
101    pub name: String,
102    #[serde(default, alias = "address")]
103    pub program_id: Option<String>,
104    pub version: String,
105    pub accounts: Vec<IdlAccountSnapshot>,
106    pub instructions: Vec<IdlInstructionSnapshot>,
107    #[serde(default)]
108    pub types: Vec<IdlTypeDefSnapshot>,
109    #[serde(default)]
110    pub events: Vec<IdlEventSnapshot>,
111    #[serde(default)]
112    pub errors: Vec<IdlErrorSnapshot>,
113    #[serde(default)]
114    pub discriminant_size: usize,
115}
116
117#[derive(Debug, Clone, Serialize)]
118pub struct IdlAccountSnapshot {
119    pub name: String,
120    pub discriminator: Vec<u8>,
121    pub docs: Vec<String>,
122    pub serialization: Option<IdlSerializationSnapshot>,
123    /// Account fields - populated from inline type definition
124    pub fields: Vec<IdlFieldSnapshot>,
125    /// Inline type definition (for Steel format with type.fields structure)
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub type_def: Option<IdlInlineTypeDef>,
128}
129
130// Intermediate struct for deserialization
131#[derive(Deserialize)]
132struct IdlAccountSnapshotIntermediate {
133    pub name: String,
134    pub discriminator: Vec<u8>,
135    #[serde(default)]
136    pub docs: Vec<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub serialization: Option<IdlSerializationSnapshot>,
139    #[serde(default)]
140    pub fields: Vec<IdlFieldSnapshot>,
141    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
142    pub type_def: Option<IdlInlineTypeDef>,
143}
144
145impl<'de> Deserialize<'de> for IdlAccountSnapshot {
146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147    where
148        D: Deserializer<'de>,
149    {
150        let intermediate = IdlAccountSnapshotIntermediate::deserialize(deserializer)?;
151
152        // Normalize fields: if empty but type_def has fields, use those
153        let fields = if intermediate.fields.is_empty() {
154            if let Some(type_def) = intermediate.type_def.as_ref() {
155                type_def.fields.clone()
156            } else {
157                intermediate.fields
158            }
159        } else {
160            intermediate.fields
161        };
162
163        Ok(IdlAccountSnapshot {
164            name: intermediate.name,
165            discriminator: intermediate.discriminator,
166            docs: intermediate.docs,
167            serialization: intermediate.serialization,
168            fields,
169            type_def: intermediate.type_def,
170        })
171    }
172}
173
174/// Inline type definition for account fields (Steel format)
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct IdlInlineTypeDef {
177    pub kind: String,
178    pub fields: Vec<IdlFieldSnapshot>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct IdlInstructionSnapshot {
183    pub name: String,
184    #[serde(default)]
185    pub discriminator: Vec<u8>,
186    #[serde(default)]
187    pub discriminant: Option<SteelDiscriminant>,
188    #[serde(default)]
189    pub docs: Vec<String>,
190    pub accounts: Vec<IdlInstructionAccountSnapshot>,
191    pub args: Vec<IdlFieldSnapshot>,
192}
193
194impl IdlInstructionSnapshot {
195    /// Get the computed 8-byte discriminator.
196    /// Returns the explicit discriminator if present, otherwise computes from discriminant.
197    pub fn get_discriminator(&self) -> Vec<u8> {
198        if !self.discriminator.is_empty() {
199            return self.discriminator.clone();
200        }
201
202        if let Some(disc) = &self.discriminant {
203            match u8::try_from(disc.value) {
204                Ok(value) => return vec![value],
205                Err(_) => {
206                    tracing::warn!(
207                        instruction = %self.name,
208                        value = disc.value,
209                        "Steel discriminant exceeds u8::MAX; falling back to Anchor hash"
210                    );
211                }
212            }
213        }
214
215        crate::discriminator::anchor_discriminator(&format!(
216            "global:{}",
217            crate::utils::to_snake_case(&self.name)
218        ))
219    }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct IdlInstructionAccountSnapshot {
224    pub name: String,
225    #[serde(default)]
226    pub writable: bool,
227    #[serde(default)]
228    pub signer: bool,
229    #[serde(default)]
230    pub optional: bool,
231    #[serde(default)]
232    pub address: Option<String>,
233    #[serde(default)]
234    pub docs: Vec<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct IdlFieldSnapshot {
239    pub name: String,
240    #[serde(rename = "type")]
241    pub type_: IdlTypeSnapshot,
242    #[serde(
243        default,
244        skip_serializing_if = "Option::is_none",
245        rename = "amountHint"
246    )]
247    pub amount_hint: Option<IdlAmountHint>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251#[serde(untagged)]
252pub enum IdlTypeSnapshot {
253    Simple(String),
254    Array(IdlArrayTypeSnapshot),
255    Option(IdlOptionTypeSnapshot),
256    Vec(IdlVecTypeSnapshot),
257    HashMap(IdlHashMapTypeSnapshot),
258    Defined(IdlDefinedTypeSnapshot),
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct IdlHashMapTypeSnapshot {
263    #[serde(rename = "hashMap", deserialize_with = "deserialize_hash_map")]
264    pub hash_map: (Box<IdlTypeSnapshot>, Box<IdlTypeSnapshot>),
265}
266
267fn deserialize_hash_map<'de, D>(
268    deserializer: D,
269) -> Result<(Box<IdlTypeSnapshot>, Box<IdlTypeSnapshot>), D::Error>
270where
271    D: Deserializer<'de>,
272{
273    use serde::de::Error;
274    let values: Vec<IdlTypeSnapshot> = Vec::deserialize(deserializer)?;
275    if values.len() != 2 {
276        return Err(D::Error::custom("hashMap must have exactly 2 elements"));
277    }
278    let mut iter = values.into_iter();
279    Ok((
280        Box::new(iter.next().expect("length checked")),
281        Box::new(iter.next().expect("length checked")),
282    ))
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct IdlArrayTypeSnapshot {
287    pub array: Vec<IdlArrayElementSnapshot>,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[serde(untagged)]
292pub enum IdlArrayElementSnapshot {
293    Type(IdlTypeSnapshot),
294    TypeName(String),
295    Size(u32),
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct IdlOptionTypeSnapshot {
300    pub option: Box<IdlTypeSnapshot>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct IdlVecTypeSnapshot {
305    pub vec: Box<IdlTypeSnapshot>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct IdlDefinedTypeSnapshot {
310    pub defined: IdlDefinedInnerSnapshot,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(untagged)]
315pub enum IdlDefinedInnerSnapshot {
316    Named { name: String },
317    Simple(String),
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "lowercase")]
322pub enum IdlSerializationSnapshot {
323    Borsh,
324    Bytemuck,
325    #[serde(alias = "bytemuckunsafe")]
326    BytemuckUnsafe,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct IdlTypeDefSnapshot {
331    pub name: String,
332    #[serde(default)]
333    pub docs: Vec<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub serialization: Option<IdlSerializationSnapshot>,
336    #[serde(rename = "type")]
337    pub type_def: IdlTypeDefKindSnapshot,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(untagged)]
342pub enum IdlTypeDefKindSnapshot {
343    Struct {
344        kind: String,
345        fields: Vec<IdlFieldSnapshot>,
346    },
347    TupleStruct {
348        kind: String,
349        fields: Vec<IdlTypeSnapshot>,
350    },
351    Enum {
352        kind: String,
353        variants: Vec<IdlEnumVariantSnapshot>,
354    },
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct IdlEnumVariantSnapshot {
359    pub name: String,
360    /// Variant payload, when the variant carries data. Skipped when empty so
361    /// fieldless variants serialize byte-identically to older snapshots.
362    #[serde(default, skip_serializing_if = "Vec::is_empty")]
363    pub fields: Vec<IdlEnumVariantFieldSnapshot>,
364}
365
366/// One field of a data-carrying enum variant (named struct field or bare
367/// tuple element).
368#[derive(Debug, Clone, Serialize, Deserialize)]
369#[serde(untagged)]
370pub enum IdlEnumVariantFieldSnapshot {
371    Named(IdlFieldSnapshot),
372    Tuple(IdlTypeSnapshot),
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct IdlEventSnapshot {
377    pub name: String,
378    pub discriminator: Vec<u8>,
379    #[serde(default)]
380    pub docs: Vec<String>,
381    #[serde(default)]
382    pub fields: Vec<IdlFieldSnapshot>,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
386pub struct IdlErrorSnapshot {
387    pub code: u32,
388    pub name: String,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub msg: Option<String>,
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_fieldless_enum_variant_wire_format_unchanged() {
399        // Fieldless variants must serialize WITHOUT a `fields` key so existing
400        // stack.json files (and the CI regenerate-diff) are unaffected by the
401        // fielded-enum extension.
402        let fieldless = IdlEnumVariantSnapshot {
403            name: "Active".to_string(),
404            fields: vec![],
405        };
406        assert_eq!(
407            serde_json::to_string(&fieldless).unwrap(),
408            r#"{"name":"Active"}"#
409        );
410
411        // Old-format JSON (no `fields`) still deserializes.
412        let parsed: IdlEnumVariantSnapshot = serde_json::from_str(r#"{"name":"Active"}"#).unwrap();
413        assert!(parsed.fields.is_empty());
414
415        // Fielded variants round-trip through both named and tuple shapes,
416        // and the untagged IdlTypeDefKindSnapshot still classifies as Enum.
417        let fielded = IdlTypeDefKindSnapshot::Enum {
418            kind: "enum".to_string(),
419            variants: vec![IdlEnumVariantSnapshot {
420                name: "Sunset".to_string(),
421                fields: vec![
422                    IdlEnumVariantFieldSnapshot::Named(IdlFieldSnapshot {
423                        name: "endTs".to_string(),
424                        type_: IdlTypeSnapshot::Simple("i64".to_string()),
425                        amount_hint: None,
426                    }),
427                    IdlEnumVariantFieldSnapshot::Tuple(IdlTypeSnapshot::Simple("u8".to_string())),
428                ],
429            }],
430        };
431        let json = serde_json::to_string(&fielded).unwrap();
432        let parsed: IdlTypeDefKindSnapshot = serde_json::from_str(&json).unwrap();
433        match parsed {
434            IdlTypeDefKindSnapshot::Enum { variants, .. } => {
435                assert_eq!(variants[0].fields.len(), 2);
436                assert!(matches!(
437                    variants[0].fields[0],
438                    IdlEnumVariantFieldSnapshot::Named(_)
439                ));
440                assert!(matches!(
441                    variants[0].fields[1],
442                    IdlEnumVariantFieldSnapshot::Tuple(_)
443                ));
444            }
445            other => panic!("untagged round-trip misclassified enum as {:?}", other),
446        }
447    }
448
449    #[test]
450    fn test_snapshot_serde() {
451        let snapshot = IdlSnapshot {
452            name: "test_program".to_string(),
453            program_id: Some("11111111111111111111111111111111".to_string()),
454            version: "0.1.0".to_string(),
455            accounts: vec![IdlAccountSnapshot {
456                name: "ExampleAccount".to_string(),
457                discriminator: vec![1, 2, 3, 4, 5, 6, 7, 8],
458                docs: vec!["Example account".to_string()],
459                serialization: Some(IdlSerializationSnapshot::Borsh),
460                fields: vec![],
461                type_def: None,
462            }],
463            instructions: vec![IdlInstructionSnapshot {
464                name: "example_instruction".to_string(),
465                discriminator: vec![8, 7, 6, 5, 4, 3, 2, 1],
466                discriminant: None,
467                docs: vec!["Example instruction".to_string()],
468                accounts: vec![IdlInstructionAccountSnapshot {
469                    name: "payer".to_string(),
470                    writable: true,
471                    signer: true,
472                    optional: false,
473                    address: None,
474                    docs: vec![],
475                }],
476                args: vec![IdlFieldSnapshot {
477                    name: "amount".to_string(),
478                    type_: IdlTypeSnapshot::HashMap(IdlHashMapTypeSnapshot {
479                        hash_map: (
480                            Box::new(IdlTypeSnapshot::Simple("u64".to_string())),
481                            Box::new(IdlTypeSnapshot::Simple("string".to_string())),
482                        ),
483                    }),
484                    amount_hint: None,
485                }],
486            }],
487            types: vec![IdlTypeDefSnapshot {
488                name: "ExampleType".to_string(),
489                docs: vec![],
490                serialization: None,
491                type_def: IdlTypeDefKindSnapshot::Struct {
492                    kind: "struct".to_string(),
493                    fields: vec![IdlFieldSnapshot {
494                        name: "value".to_string(),
495                        type_: IdlTypeSnapshot::Simple("u64".to_string()),
496                        amount_hint: None,
497                    }],
498                },
499            }],
500            events: vec![IdlEventSnapshot {
501                name: "ExampleEvent".to_string(),
502                discriminator: vec![0, 0, 0, 0, 0, 0, 0, 1],
503                docs: vec![],
504                fields: vec![],
505            }],
506            errors: vec![IdlErrorSnapshot {
507                code: 6000,
508                name: "ExampleError".to_string(),
509                msg: Some("example".to_string()),
510            }],
511            discriminant_size: 8,
512        };
513
514        let serialized = serde_json::to_value(&snapshot).expect("serialize snapshot");
515        let deserialized: IdlSnapshot =
516            serde_json::from_value(serialized.clone()).expect("deserialize snapshot");
517        let round_trip = serde_json::to_value(&deserialized).expect("re-serialize snapshot");
518
519        assert_eq!(serialized, round_trip);
520        assert_eq!(deserialized.name, "test_program");
521    }
522
523    #[test]
524    fn test_hashmap_compat() {
525        let json = r#"{"hashMap":["u64","string"]}"#;
526        let parsed: IdlHashMapTypeSnapshot =
527            serde_json::from_str(json).expect("deserialize hashMap");
528
529        assert!(matches!(
530            parsed.hash_map.0.as_ref(),
531            IdlTypeSnapshot::Simple(value) if value == "u64"
532        ));
533        assert!(matches!(
534            parsed.hash_map.1.as_ref(),
535            IdlTypeSnapshot::Simple(value) if value == "string"
536        ));
537    }
538}