Skip to main content

hyperstack_idl/analysis/
type_graph.rs

1//! Type graph analysis — extracts pubkey field references from IDL type definitions.
2
3use crate::types::{IdlSpec, IdlType, IdlTypeDefKind};
4use crate::utils::to_pascal_case;
5
6/// A reference to a pubkey field within a type definition.
7#[derive(Debug, Clone)]
8pub struct PubkeyFieldRef {
9    /// The field name (e.g. "lb_pair", "owner").
10    pub field_name: String,
11    /// Inferred target account type, matched by converting the field name
12    /// (or field name stripped of `_id`/`_key` suffix) to PascalCase and
13    /// comparing against account type names.
14    pub likely_target: Option<String>,
15}
16
17/// A node in the type graph representing a type that contains pubkey fields.
18#[derive(Debug, Clone)]
19pub struct TypeNode {
20    /// The type name (e.g. "Position", "LbPair").
21    pub type_name: String,
22    /// All pubkey fields found in this type.
23    pub pubkey_fields: Vec<PubkeyFieldRef>,
24}
25
26/// Extract a type graph from an IDL spec.
27///
28/// Scans all type definitions (`idl.types`) for struct fields of type `pubkey`.
29/// For each pubkey field, attempts to infer the target account type by matching
30/// the field name (or field name with `_id`/`_key` suffix stripped) against
31/// account type names (from `idl.accounts`).
32///
33/// Only types with at least one pubkey field are included in the result.
34pub fn extract_type_graph(idl: &IdlSpec) -> Vec<TypeNode> {
35    let account_names: Vec<&str> = idl.accounts.iter().map(|a| a.name.as_str()).collect();
36
37    let mut nodes = Vec::new();
38
39    for type_def in &idl.types {
40        let fields = match &type_def.type_def {
41            IdlTypeDefKind::Struct { fields, .. } => fields,
42            _ => continue,
43        };
44
45        let pubkey_fields: Vec<PubkeyFieldRef> = fields
46            .iter()
47            .filter(|f| is_pubkey_type(&f.type_))
48            .map(|f| {
49                let likely_target = infer_target(&f.name, &account_names);
50                PubkeyFieldRef {
51                    field_name: f.name.clone(),
52                    likely_target,
53                }
54            })
55            .collect();
56
57        if !pubkey_fields.is_empty() {
58            nodes.push(TypeNode {
59                type_name: type_def.name.clone(),
60                pubkey_fields,
61            });
62        }
63    }
64
65    nodes
66}
67
68/// Check if an `IdlType` represents a public key.
69fn is_pubkey_type(ty: &IdlType) -> bool {
70    matches!(ty, IdlType::Simple(s) if s == "pubkey" || s == "publicKey")
71}
72
73/// Attempt to match a field name to an account type name.
74///
75/// Strategy: convert the field name (and variants with `_id`/`_key` stripped)
76/// to PascalCase and compare against known account type names (case-insensitive).
77fn infer_target(field_name: &str, account_names: &[&str]) -> Option<String> {
78    let candidates = stripped_candidates(field_name);
79
80    for candidate in &candidates {
81        let pascal = to_pascal_case(candidate);
82        for &acct in account_names {
83            if acct.eq_ignore_ascii_case(&pascal) {
84                return Some(acct.to_string());
85            }
86        }
87    }
88
89    None
90}
91
92/// Generate candidate base names by stripping common suffixes.
93fn stripped_candidates(field_name: &str) -> Vec<&str> {
94    let mut candidates = vec![field_name];
95
96    for suffix in &["_id", "_key"] {
97        if let Some(stripped) = field_name.strip_suffix(suffix) {
98            if !stripped.is_empty() {
99                candidates.push(stripped);
100            }
101        }
102    }
103
104    candidates
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::parse::parse_idl_file;
111    use std::path::PathBuf;
112
113    fn meteora_fixture() -> IdlSpec {
114        let path =
115            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/meteora_dlmm.json");
116        parse_idl_file(&path).expect("should parse meteora_dlmm.json")
117    }
118
119    #[test]
120    fn test_type_graph() {
121        let idl = meteora_fixture();
122        let graph = extract_type_graph(&idl);
123
124        // Should have at least one TypeNode
125        assert!(
126            !graph.is_empty(),
127            "should extract type nodes with pubkey fields"
128        );
129
130        // Find the Position type
131        let position = graph.iter().find(|n| n.type_name == "Position");
132        assert!(
133            position.is_some(),
134            "Position type should be in the type graph"
135        );
136        let position = position.unwrap();
137
138        // Position should have lb_pair as a pubkey field
139        let lb_pair_field = position
140            .pubkey_fields
141            .iter()
142            .find(|f| f.field_name == "lb_pair");
143        assert!(
144            lb_pair_field.is_some(),
145            "Position should have lb_pair pubkey field"
146        );
147        assert_eq!(
148            lb_pair_field.unwrap().likely_target.as_deref(),
149            Some("LbPair"),
150            "lb_pair should resolve to LbPair account type"
151        );
152
153        // Position should also have owner as a pubkey field
154        let owner_field = position
155            .pubkey_fields
156            .iter()
157            .find(|f| f.field_name == "owner");
158        assert!(
159            owner_field.is_some(),
160            "Position should have owner pubkey field"
161        );
162
163        // Print summary for evidence
164        println!("Type graph nodes: {}", graph.len());
165        for node in &graph {
166            println!(
167                "  {} — pubkey fields: {:?}",
168                node.type_name,
169                node.pubkey_fields
170                    .iter()
171                    .map(|f| format!(
172                        "{} -> {}",
173                        f.field_name,
174                        f.likely_target.as_deref().unwrap_or("?")
175                    ))
176                    .collect::<Vec<_>>()
177            );
178        }
179    }
180
181    #[test]
182    fn test_is_pubkey_type() {
183        assert!(is_pubkey_type(&IdlType::Simple("pubkey".to_string())));
184        assert!(is_pubkey_type(&IdlType::Simple("publicKey".to_string())));
185        assert!(!is_pubkey_type(&IdlType::Simple("u64".to_string())));
186        assert!(!is_pubkey_type(&IdlType::Simple("bool".to_string())));
187    }
188
189    #[test]
190    fn test_stripped_candidates() {
191        assert_eq!(stripped_candidates("lb_pair"), vec!["lb_pair"]);
192        assert_eq!(stripped_candidates("pool_id"), vec!["pool_id", "pool"]);
193        assert_eq!(stripped_candidates("mint_key"), vec!["mint_key", "mint"]);
194        assert_eq!(stripped_candidates("_id"), vec!["_id"]);
195    }
196
197    #[test]
198    fn test_infer_target() {
199        let accounts = vec!["LbPair", "Position", "BinArray"];
200
201        assert_eq!(
202            infer_target("lb_pair", &accounts),
203            Some("LbPair".to_string())
204        );
205        assert_eq!(
206            infer_target("position", &accounts),
207            Some("Position".to_string())
208        );
209        assert_eq!(
210            infer_target("bin_array_id", &accounts),
211            Some("BinArray".to_string()),
212            "should match after stripping _id suffix"
213        );
214        assert_eq!(
215            infer_target("unknown_field", &accounts),
216            None,
217            "should return None for non-matching fields"
218        );
219    }
220}