Skip to main content

stacksdapp_parser/
lib.rs

1use anyhow::{anyhow, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ContractAbi {
7    pub contract_id: String,
8    pub contract_name: String,
9    pub functions: Vec<AbiFunction>,
10    pub variables: Vec<AbiVariable>,
11    pub maps: Vec<AbiMap>,
12    pub fungible_tokens: Vec<String>,
13    pub non_fungible_tokens: Vec<AbiNft>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AbiFunction {
18    pub name: String,
19    pub access: FunctionAccess,
20    pub args: Vec<AbiArg>,
21    pub outputs: AbiType,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "snake_case")]
26pub enum FunctionAccess {
27    Public,
28    ReadOnly,
29    Private,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AbiArg {
34    pub name: String,
35    pub r#type: AbiType,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum AbiType {
41    Simple(String),
42    // SDK emits "string-ascii" (hyphen) — rename to match
43    StringAscii {
44        #[serde(rename = "string-ascii")]
45        string_ascii: StringLen,
46    },
47    // SDK emits "string-utf8" (hyphen) — rename to match
48    StringUtf8 {
49        #[serde(rename = "string-utf8")]
50        string_utf8: StringLen,
51    },
52    // SDK emits { "buffer": { "length": N } }
53    Buffer {
54        buffer: StringLen,
55    },
56    // Legacy { "buff": N }
57    Buff {
58        buff: u32,
59    },
60    List {
61        list: ListDef,
62    },
63    Tuple {
64        tuple: Vec<TupleEntry>,
65    },
66    Optional {
67        optional: Box<AbiType>,
68    },
69    Response {
70        response: ResponseDef,
71    },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct StringLen {
76    pub length: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ListDef {
81    pub r#type: Box<AbiType>,
82    pub length: u32,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct TupleEntry {
87    pub name: String,
88    pub r#type: AbiType,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ResponseDef {
93    pub ok: Box<AbiType>,
94    pub error: Box<AbiType>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct AbiVariable {
99    pub name: String,
100    pub access: String,
101    pub r#type: AbiType,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct AbiMap {
106    pub name: String,
107    pub key: AbiType,
108    pub value: AbiType,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AbiNft {
113    pub name: String,
114    pub r#type: AbiType,
115}
116
117pub async fn parse_project(contracts_dir: &Path) -> Result<Vec<ContractAbi>> {
118    use tokio::process::Command;
119
120    let clarinet_toml = contracts_dir.join("Clarinet.toml");
121    if !clarinet_toml.exists() {
122        return Err(anyhow!(
123            "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
124        ));
125    }
126
127    // The export-abi script lives in frontend/scripts/ but must be run
128    // with CWD = contracts/ so that initSimnet() finds Clarinet.toml and
129    // settings/Devnet.toml in the current directory — exactly where they are.
130    let project_root = contracts_dir
131        .parent()
132        .ok_or_else(|| anyhow!("Invalid contracts path"))?;
133    let script = project_root
134        .join("frontend")
135        .join("scripts")
136        .join("export-abi.mjs");
137
138    if !script.exists() {
139        return Err(anyhow!(
140            "ABI export script not found at {}. Re-scaffold or add frontend/scripts/export-abi.mjs.",
141            script.display()
142        ));
143    }
144
145    // Resolve the script path to an absolute path before changing CWD.
146    let script_abs = script
147        .canonicalize()
148        .map_err(|e| anyhow!("Cannot resolve script path {}: {e}", script.display()))?;
149
150    // Run from contracts/ so initSimnet() resolves Clarinet.toml + settings/Devnet.toml correctly.
151    let output = Command::new("node")
152        .arg(&script_abs)
153        .current_dir(contracts_dir) // <-- KEY FIX: CWD must be contracts/
154        .output()
155        .await
156        .map_err(|e| {
157            if e.kind() == std::io::ErrorKind::NotFound {
158                anyhow!("Node.js is required to export ABIs. Install from nodejs.org")
159            } else {
160                anyhow!("Failed to run export-abi script: {e}")
161            }
162        })?;
163
164    if !output.status.success() {
165        let stderr = String::from_utf8_lossy(&output.stderr);
166        return Err(anyhow!(
167            "Failed to export contract ABIs. Run clarinet check to validate contracts.\n{}",
168            if stderr.is_empty() {
169                "Script exited non-zero.".to_string()
170            } else {
171                stderr.trim().to_string()
172            }
173        ));
174    }
175
176    let stdout = String::from_utf8(output.stdout)?;
177    let stderr = String::from_utf8_lossy(&output.stderr);
178
179    // Keep nested quiet generates clean (e.g. `stacksdapp dev` spinner steps).
180    let quiet = std::env::var_os("STACKSDAPP_QUIET").is_some();
181    if !quiet && !stderr.trim().is_empty() {
182        eprintln!("[export-abi] {}", stderr.trim());
183    }
184
185    // initSimnet() writes status lines like "Updated deployment plan file"
186    // to stdout before the JSON array. Find the first '[' and slice from there.
187    let json_start = stdout.find('[').ok_or_else(|| anyhow!(
188        "export-abi.mjs produced no JSON. Run: cd contracts && node ../frontend/scripts/export-abi.mjs\nOutput: {}",
189        &stdout[..stdout.len().min(300)]
190    ))?;
191    let json = stdout[json_start..].trim();
192
193    parse_abi_list(json)
194}
195
196/// Parse a JSON array of ContractAbi (e.g. from export-abi.mjs stdout).
197pub fn parse_abi_list(json: &str) -> Result<Vec<ContractAbi>> {
198    serde_json::from_str(json).map_err(|e| {
199        anyhow!(
200            "Failed to parse ABI JSON: {e}.
201             First 200 chars of output: {}",
202            &json[..json.len().min(200)]
203        )
204    })
205}
206
207/// Parse a single ABI JSON string (for testing).
208pub fn parse_abi(json: &str) -> Result<ContractAbi> {
209    let abi = serde_json::from_str(json)?;
210    Ok(abi)
211}
212
213/// Map an AbiType into a TypeScript type string.
214pub fn abi_type_to_ts(t: &AbiType) -> String {
215    match t {
216        AbiType::Simple(s) => match s.as_str() {
217            "uint128" | "int128" => "bigint".to_string(),
218            "bool" => "boolean".to_string(),
219            "principal" => "string".to_string(),
220            _ => "unknown".to_string(),
221        },
222        AbiType::StringAscii { .. } | AbiType::StringUtf8 { .. } => "string".to_string(),
223        AbiType::Buffer { .. } | AbiType::Buff { .. } => "Uint8Array".to_string(),
224        AbiType::List { list } => {
225            let inner = abi_type_to_ts(&list.r#type);
226            format!("Array<{inner}>")
227        }
228        AbiType::Tuple { tuple } => {
229            let fields: Vec<String> = tuple
230                .iter()
231                .map(|e| format!("{}: {}", e.name, abi_type_to_ts(&e.r#type)))
232                .collect();
233            format!("{{ {} }}", fields.join(", "))
234        }
235        AbiType::Optional { optional } => format!("{} | null", abi_type_to_ts(optional)),
236        AbiType::Response { response } => {
237            let ok = abi_type_to_ts(&response.ok);
238            let err = abi_type_to_ts(&response.error);
239            format!("{{ ok: {ok} }} | {{ error: {err} }}")
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::{abi_type_to_ts, parse_abi, parse_abi_list, AbiType};
247
248    #[test]
249    fn parse_abi_list_accepts_empty_array() {
250        let abis = parse_abi_list("[]").unwrap();
251        assert!(abis.is_empty());
252    }
253
254    #[test]
255    fn parse_abi_list_rejects_non_array_json() {
256        assert!(parse_abi_list("{}").is_err());
257        assert!(parse_abi_list("null").is_err());
258        assert!(parse_abi_list("not-json").is_err());
259    }
260
261    #[test]
262    fn parse_abi_list_rejects_truncated_json() {
263        assert!(parse_abi_list("[{\"contract_name\":").is_err());
264    }
265
266    #[test]
267    fn parse_abi_list_rejects_missing_required_fields() {
268        let json = r#"[{"contract_name":"counter"}]"#;
269        assert!(parse_abi_list(json).is_err());
270    }
271
272    #[test]
273    fn parse_abi_parses_minimal_contract() {
274        let json = r#"{
275            "contract_id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter",
276            "contract_name": "counter",
277            "functions": [],
278            "variables": [],
279            "maps": [],
280            "fungible_tokens": [],
281            "non_fungible_tokens": []
282        }"#;
283        let abi = parse_abi(json).unwrap();
284        assert_eq!(abi.contract_name, "counter");
285    }
286
287    #[test]
288    fn abi_type_to_ts_maps_primitives() {
289        assert_eq!(abi_type_to_ts(&AbiType::Simple("uint128".into())), "bigint");
290        assert_eq!(abi_type_to_ts(&AbiType::Simple("bool".into())), "boolean");
291        assert_eq!(
292            abi_type_to_ts(&AbiType::Simple("unknown-type".into())),
293            "unknown"
294        );
295    }
296}