Skip to main content

stacksdapp_codegen/
lib.rs

1use anyhow::Result;
2use sha2::{Digest, Sha256};
3use stacksdapp_parser::ContractAbi;
4use std::collections::HashMap;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use tera::{Filter, Tera, Value};
9
10const CONTRACTS_TS_TEMPLATE: &str = include_str!(concat!(
11    env!("CARGO_MANIFEST_DIR"),
12    "/templates/contracts.ts.tera"
13));
14const HOOKS_TS_TEMPLATE: &str = include_str!(concat!(
15    env!("CARGO_MANIFEST_DIR"),
16    "/templates/hooks.ts.tera"
17));
18const DEBUG_UI_TSX_TEMPLATE: &str = include_str!(concat!(
19    env!("CARGO_MANIFEST_DIR"),
20    "/templates/debug_ui.tsx.tera"
21));
22
23// ── Custom Tera filters ───────────────────────────────────────────────────────
24
25fn to_camel_case(s: &str) -> String {
26    let mut result = String::new();
27    let mut capitalize_next = false;
28    for (i, ch) in s.chars().enumerate() {
29        if ch == '-' || ch == '_' {
30            capitalize_next = true;
31        } else if capitalize_next {
32            result.extend(ch.to_uppercase());
33            capitalize_next = false;
34        } else if i == 0 {
35            result.extend(ch.to_lowercase());
36        } else {
37            result.push(ch);
38        }
39    }
40    result
41}
42
43fn to_upper_camel_case(s: &str) -> String {
44    let camel = to_camel_case(s);
45    let mut chars = camel.chars();
46    match chars.next() {
47        None => String::new(),
48        Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
49    }
50}
51
52struct CamelFilter;
53impl Filter for CamelFilter {
54    fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
55        match value.as_str() {
56            Some(s) => Ok(Value::String(to_camel_case(s))),
57            None => Err(tera::Error::msg("camel filter: expected string")),
58        }
59    }
60}
61
62struct UpperCamelFilter;
63impl Filter for UpperCamelFilter {
64    fn filter(&self, value: &Value, _args: &HashMap<String, Value>) -> tera::Result<Value> {
65        match value.as_str() {
66            Some(s) => Ok(Value::String(to_upper_camel_case(s))),
67            None => Err(tera::Error::msg("upper_camel filter: expected string")),
68        }
69    }
70}
71
72// ── Public API ────────────────────────────────────────────────────────────────
73
74pub async fn generate_all() -> Result<()> {
75    let project_root = std::env::current_dir()?;
76    let contracts_dir = project_root.join("contracts");
77    if !contracts_dir.join("Clarinet.toml").exists()
78        || !project_root.join("frontend/package.json").exists()
79    {
80        anyhow::bail!(
81            "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
82        );
83    }
84
85    let frontend_dir = project_root.join("frontend");
86    if !frontend_dir.join("node_modules").exists() {
87        println!("[generate] Installing frontend dependencies...");
88        let subcommand = if frontend_dir.join("package-lock.json").exists() {
89            "ci"
90        } else {
91            "install"
92        };
93        let status = tokio::process::Command::new("npm")
94            .arg(subcommand)
95            .args([
96                "--no-audit",
97                "--no-fund",
98                "--prefer-offline",
99                "--progress=false",
100                "--loglevel=error",
101            ])
102            .current_dir(&frontend_dir)
103            .status()
104            .await?;
105        if !status.success() {
106            anyhow::bail!("npm install in frontend/ failed.");
107        }
108    }
109
110    println!("[generate] Parsing contract ABIs...");
111    let abis = stacksdapp_parser::parse_project(&contracts_dir).await?;
112
113    if abis.is_empty() {
114        println!("[generate] No user contracts found in Clarinet.toml — nothing to generate.");
115        return Ok(());
116    }
117
118    println!(
119        "[generate] Found {} contract(s): {}",
120        abis.len(),
121        abis.iter()
122            .map(|a| a.contract_name.as_str())
123            .collect::<Vec<_>>()
124            .join(", ")
125    );
126
127    let out_dir = project_root.join("frontend/src/generated");
128    tokio::fs::create_dir_all(&out_dir).await?;
129
130    // Write empty deployments.json if it doesn't exist yet so that
131    // contracts.ts can always require() it without crashing at import time.
132    // The real content is written by `stacksdapp deploy`.
133    let deployments_path = out_dir.join("deployments.json");
134    if !deployments_path.exists() {
135        tokio::fs::write(
136            &deployments_path,
137            r#"{ "network": "", "deployed_at": "", "contracts": {} }"#,
138        )
139        .await?;
140        println!("[generate] Created empty deployments.json (run stacksdapp deploy to populate)");
141    }
142
143    let written = render(&abis, &out_dir)?;
144
145    if written == 0 {
146        println!("[generate] All files already up to date.");
147    } else {
148        println!("[generate] Done — {written} file(s) written.");
149    }
150
151    let network = std::env::var("NEXT_PUBLIC_NETWORK").unwrap_or_else(|_| "<network>".into());
152    let stale = find_stale_deployments(&abis, &out_dir);
153    if !stale.is_empty() {
154        warn_redeploy_required(&stale, &network);
155    }
156
157    Ok(())
158}
159
160/// Render all templates. Returns the number of files actually written.
161pub fn render(abis: &[ContractAbi], out_dir: &Path) -> Result<usize> {
162    let mut tera = Tera::default();
163    tera.register_filter("camel", CamelFilter);
164    tera.register_filter("upper_camel", UpperCamelFilter);
165
166    tera.add_raw_template("contracts.ts.tera", CONTRACTS_TS_TEMPLATE)?;
167    tera.add_raw_template("hooks.ts.tera", HOOKS_TS_TEMPLATE)?;
168    tera.add_raw_template("debug_ui.tsx.tera", DEBUG_UI_TSX_TEMPLATE)?;
169
170    // Serialize ABIs and enrich each function arg with a `type_str` field —
171    // a simple lowercase Clarity type string (e.g. "uint128", "bool", "principal",
172    // "string-ascii", "string-utf8", "buff") used by the debug UI to build
173    // typed inputs and call toClarityValue() correctly.
174    let contracts_json: Vec<serde_json::Value> = abis
175        .iter()
176        .map(|c| {
177            let mut val = serde_json::to_value(c).expect("ContractAbi serialization failed");
178            if let Some(fns) = val["functions"].as_array_mut() {
179                for f in fns.iter_mut() {
180                    if let Some(args) = f["args"].as_array_mut() {
181                        for arg in args.iter_mut() {
182                            let type_str = clarity_type_str(&arg["type"]);
183                            arg["type_str"] = serde_json::Value::String(type_str);
184                        }
185                    }
186                }
187            }
188            val
189        })
190        .collect();
191
192    let ctx = tera::Context::from_serialize(serde_json::json!({
193        "contracts": contracts_json
194    }))?;
195
196    let mut written = 0;
197    written += write_if_changed(
198        out_dir.join("contracts.ts"),
199        &tera.render("contracts.ts.tera", &ctx)?,
200    )?;
201    written += write_if_changed(
202        out_dir.join("hooks.ts"),
203        &tera.render("hooks.ts.tera", &ctx)?,
204    )?;
205    written += write_if_changed(
206        out_dir.join("DebugContracts.tsx"),
207        &tera.render("debug_ui.tsx.tera", &ctx)?,
208    )?;
209
210    Ok(written)
211}
212
213// ── Helpers ───────────────────────────────────────────────────────────────────
214
215/// Convert a serialized AbiType JSON value into a simple Clarity type string
216/// for use in the debug UI. e.g. uint128 → "uint128", string-ascii → "string-ascii"
217fn clarity_type_str(t: &serde_json::Value) -> String {
218    match t {
219        serde_json::Value::String(s) => s.clone(),
220        serde_json::Value::Object(map) => {
221            if map.contains_key("string-ascii") {
222                return "string-ascii".into();
223            }
224            if map.contains_key("string-utf8") {
225                return "string-utf8".into();
226            }
227            if map.contains_key("buffer") {
228                return "buff".into();
229            }
230            if map.contains_key("buff") {
231                return "buff".into();
232            }
233            if map.contains_key("list") {
234                return "list".into();
235            }
236            if map.contains_key("tuple") {
237                return "tuple".into();
238            }
239            if map.contains_key("optional") {
240                return "optional".into();
241            }
242            if map.contains_key("response") {
243                return "response".into();
244            }
245            "unknown".into()
246        }
247        _ => "unknown".into(),
248    }
249}
250
251fn hash_bytes(bytes: &[u8]) -> Vec<u8> {
252    let mut hasher = Sha256::new();
253    hasher.update(bytes);
254    hasher.finalize().to_vec()
255}
256
257fn find_stale_deployments(abis: &[ContractAbi], out_dir: &Path) -> Vec<String> {
258    let deployments_path = out_dir.join("deployments.json");
259    let Ok(raw) = std::fs::read_to_string(&deployments_path) else {
260        return vec![]; // no deployments yet — nothing to compare
261    };
262    let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
263        return vec![];
264    };
265
266    let deployed = json["contracts"].as_object();
267
268    abis.iter()
269        .filter(|abi| {
270            match deployed.and_then(|d| d.get(&abi.contract_name)) {
271                None => true, // never deployed
272                Some(entry) => {
273                    let deployed_id = entry["contract_id"].as_str().unwrap_or("");
274                    // If the deployed name doesn't end with the current contract name,
275                    // the contract has been renamed (versioned) and needs redeployment
276                    !deployed_id.ends_with(&format!(".{}", abi.contract_name))
277                }
278            }
279        })
280        .map(|abi| abi.contract_name.clone())
281        .collect()
282}
283
284/// Write file only if content changed. Returns 1 if written, 0 if skipped.
285fn write_if_changed(path: PathBuf, contents: &str) -> Result<usize> {
286    let new_bytes = contents.as_bytes();
287    let new_hash = hash_bytes(new_bytes);
288
289    if let Ok(existing) = fs::read(&path) {
290        if hash_bytes(&existing) == new_hash {
291            return Ok(0);
292        }
293    }
294
295    if let Some(parent) = path.parent() {
296        fs::create_dir_all(parent)?;
297    }
298    let mut file = fs::File::create(&path)?;
299    file.write_all(new_bytes)?;
300    println!("[generated] {}", path.display());
301    Ok(1)
302}
303
304/// Print a prominent redeployment warning.
305fn warn_redeploy_required(stale: &[String], network: &str) {
306    let names = stale.join(", ");
307    eprintln!("\n{}", "━".repeat(60));
308    eprintln!("  ⚠  REDEPLOYMENT REQUIRED");
309    eprintln!("{}", "━".repeat(60));
310    eprintln!("  Contracts on-chain are out of sync with local source:");
311    eprintln!("  {}", names);
312    eprintln!();
313    eprintln!("  Clarity contracts are immutable. Your changes won't take");
314    eprintln!("  effect until you redeploy:");
315    eprintln!();
316    eprintln!("    stacksdapp deploy --network {network}");
317    eprintln!("    where network is either devnet/testnet/mainnet");
318    eprintln!();
319    eprintln!("  Until then, calls to new/changed functions will fail.");
320    eprintln!("{}\n", "━".repeat(60));
321}