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 { buffer: StringLen },
54    // Legacy { "buff": N }
55    Buff { buff: u32 },
56    List { list: ListDef },
57    Tuple { tuple: Vec<TupleEntry> },
58    Optional { optional: Box<AbiType> },
59    Response { response: ResponseDef },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct StringLen {
64    pub length: u32,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ListDef {
69    pub r#type: Box<AbiType>,
70    pub length: u32,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TupleEntry {
75    pub name: String,
76    pub r#type: AbiType,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ResponseDef {
81    pub ok: Box<AbiType>,
82    pub error: Box<AbiType>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct AbiVariable {
87    pub name: String,
88    pub access: String,
89    pub r#type: AbiType,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AbiMap {
94    pub name: String,
95    pub key: AbiType,
96    pub value: AbiType,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct AbiNft {
101    pub name: String,
102    pub r#type: AbiType,
103}
104
105pub async fn parse_project(contracts_dir: &Path) -> Result<Vec<ContractAbi>> {
106    use tokio::process::Command;
107
108    let clarinet_toml = contracts_dir.join("Clarinet.toml");
109    if !clarinet_toml.exists() {
110        return Err(anyhow!(
111            "No scaffold-stacks project found. Run from the directory created by stacks-dapp new"
112        ));
113    }
114
115    // The export-abi script lives in frontend/scripts/ but must be run
116    // with CWD = contracts/ so that initSimnet() finds Clarinet.toml and
117    // settings/Devnet.toml in the current directory — exactly where they are.
118    let project_root = contracts_dir
119        .parent()
120        .ok_or_else(|| anyhow!("Invalid contracts path"))?;
121    let script = project_root
122        .join("frontend")
123        .join("scripts")
124        .join("export-abi.mjs");
125
126    if !script.exists() {
127        return Err(anyhow!(
128            "ABI export script not found at {}. Re-scaffold or add frontend/scripts/export-abi.mjs.",
129            script.display()
130        ));
131    }
132
133    // Resolve the script path to an absolute path before changing CWD.
134    let script_abs = script
135        .canonicalize()
136        .map_err(|e| anyhow!("Cannot resolve script path {}: {e}", script.display()))?;
137
138    // Run from contracts/ so initSimnet() resolves Clarinet.toml + settings/Devnet.toml correctly.
139    let output = Command::new("node")
140        .arg(&script_abs)
141        .current_dir(contracts_dir) // <-- KEY FIX: CWD must be contracts/
142        .output()
143        .await
144        .map_err(|e| {
145            if e.kind() == std::io::ErrorKind::NotFound {
146                anyhow!("Node.js is required to export ABIs. Install from nodejs.org")
147            } else {
148                anyhow!("Failed to run export-abi script: {e}")
149            }
150        })?;
151
152    if !output.status.success() {
153        let stderr = String::from_utf8_lossy(&output.stderr);
154        return Err(anyhow!(
155            "Failed to export contract ABIs. Run clarinet check to validate contracts.\n{}",
156            if stderr.is_empty() {
157                "Script exited non-zero.".to_string()
158            } else {
159                stderr.trim().to_string()
160            }
161        ));
162    }
163
164    let stdout = String::from_utf8(output.stdout)?;
165    let stderr = String::from_utf8_lossy(&output.stderr);
166
167    // Print stderr always so the developer sees what the script actually said
168    if !stderr.trim().is_empty() {
169        eprintln!("[export-abi] {}", stderr.trim());
170    }
171
172    // initSimnet() writes status lines like "Updated deployment plan file"
173    // to stdout before the JSON array. Find the first '[' and slice from there.
174    let json_start = stdout.find('[').ok_or_else(|| anyhow!(
175        "export-abi.mjs produced no JSON. Run: cd contracts && node ../frontend/scripts/export-abi.mjs\nOutput: {}",
176        &stdout[..stdout.len().min(300)]
177    ))?;
178    let json = stdout[json_start..].trim();
179
180    parse_abi_list(json)
181}
182
183/// Parse a JSON array of ContractAbi (e.g. from export-abi.mjs stdout).
184pub fn parse_abi_list(json: &str) -> Result<Vec<ContractAbi>> {
185    serde_json::from_str(json).map_err(|e| {
186        anyhow!(
187            "Failed to parse ABI JSON: {e}.
188             First 200 chars of output: {}",
189            &json[..json.len().min(200)]
190        )
191    })
192}
193
194/// Parse a single ABI JSON string (for testing).
195pub fn parse_abi(json: &str) -> Result<ContractAbi> {
196    let abi = serde_json::from_str(json)?;
197    Ok(abi)
198}
199
200/// Map an AbiType into a TypeScript type string.
201pub fn abi_type_to_ts(t: &AbiType) -> String {
202    match t {
203        AbiType::Simple(s) => match s.as_str() {
204            "uint128" | "int128" => "bigint".to_string(),
205            "bool" => "boolean".to_string(),
206            "principal" => "string".to_string(),
207            _ => "unknown".to_string(),
208        },
209        AbiType::StringAscii { .. } | AbiType::StringUtf8 { .. } => "string".to_string(),
210        AbiType::Buffer { .. } | AbiType::Buff { .. } => "Uint8Array".to_string(),
211        AbiType::List { list } => {
212            let inner = abi_type_to_ts(&list.r#type);
213            format!("Array<{inner}>")
214        }
215        AbiType::Tuple { tuple } => {
216            let fields: Vec<String> = tuple
217                .iter()
218                .map(|e| format!("{}: {}", e.name, abi_type_to_ts(&e.r#type)))
219                .collect();
220            format!("{{ {} }}", fields.join(", "))
221        }
222        AbiType::Optional { optional } => format!("{} | null", abi_type_to_ts(optional)),
223        AbiType::Response { response } => {
224            let ok = abi_type_to_ts(&response.ok);
225            let err = abi_type_to_ts(&response.error);
226            format!("{{ ok: {ok} }} | {{ error: {err} }}")
227        }
228    }
229}