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    generate_all_impl(false).await
76}
77
78/// Same as [`generate_all`] but suppresses progress logs (for nested CLI steps).
79pub async fn generate_all_quiet() -> Result<()> {
80    generate_all_impl(true).await
81}
82
83async fn generate_all_impl(quiet: bool) -> Result<()> {
84    let project_root = std::env::current_dir()?;
85    let contracts_dir = project_root.join("contracts");
86    if !contracts_dir.join("Clarinet.toml").exists()
87        || !project_root.join("frontend/package.json").exists()
88    {
89        anyhow::bail!(
90            "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
91        );
92    }
93
94    // Silence nested export-abi status lines while a parent spinner owns the TTY.
95    if quiet {
96        std::env::set_var("STACKSDAPP_QUIET", "1");
97    }
98
99    let log = |msg: String| {
100        if !quiet {
101            println!("{msg}");
102        }
103    };
104
105    let frontend_dir = project_root.join("frontend");
106    if !frontend_dir.join("node_modules").exists() {
107        log("[generate] Installing frontend dependencies...".into());
108        let subcommand = if frontend_dir.join("package-lock.json").exists() {
109            "ci"
110        } else {
111            "install"
112        };
113        let status = tokio::process::Command::new("npm")
114            .arg(subcommand)
115            .args([
116                "--no-audit",
117                "--no-fund",
118                "--prefer-offline",
119                "--progress=false",
120                "--loglevel=error",
121            ])
122            .current_dir(&frontend_dir)
123            .status()
124            .await?;
125        if !status.success() {
126            anyhow::bail!("npm install in frontend/ failed.");
127        }
128    }
129
130    log("[generate] Parsing contract ABIs...".into());
131    let abis = stacksdapp_parser::parse_project(&contracts_dir).await?;
132
133    let out_dir = project_root.join("frontend/src/generated");
134    tokio::fs::create_dir_all(&out_dir).await?;
135
136    let deployments_path = out_dir.join("deployments.json");
137    if !deployments_path.exists() {
138        tokio::fs::write(
139            &deployments_path,
140            r#"{ "network": "", "deployed_at": "", "contracts": {} }"#,
141        )
142        .await?;
143        log("[generate] Created empty deployments.json (run stacksdapp deploy to populate)".into());
144    }
145
146    if abis.is_empty() {
147        let written = render_with_quiet(&abis, &out_dir, quiet)?;
148        if written == 0 {
149            log("[generate] No user contracts found in Clarinet.toml — generated stubs already up to date.".into());
150        } else {
151            log("[generate] No user contracts found in Clarinet.toml — wrote empty generated stubs.".into());
152        }
153        return Ok(());
154    }
155
156    log(format!(
157        "[generate] Found {} contract(s): {}",
158        abis.len(),
159        abis.iter()
160            .map(|a| a.contract_name.as_str())
161            .collect::<Vec<_>>()
162            .join(", ")
163    ));
164    let written = render_with_quiet(&abis, &out_dir, quiet)?;
165
166    if written == 0 {
167        log("[generate] All files already up to date.".into());
168    } else {
169        log(format!("[generate] Done — {written} file(s) written."));
170    }
171
172    let network = std::env::var("NEXT_PUBLIC_NETWORK").unwrap_or_else(|_| "<network>".into());
173    let stale = find_stale_deployments(&abis, &out_dir);
174    if !stale.is_empty() && !quiet {
175        warn_redeploy_required(&stale, &network);
176    }
177
178    Ok(())
179}
180
181/// Render all templates. Returns the number of files actually written.
182pub fn render(abis: &[ContractAbi], out_dir: &Path) -> Result<usize> {
183    render_with_quiet(abis, out_dir, false)
184}
185
186fn render_with_quiet(abis: &[ContractAbi], out_dir: &Path, quiet: bool) -> Result<usize> {
187    let mut tera = Tera::default();
188    tera.register_filter("camel", CamelFilter);
189    tera.register_filter("upper_camel", UpperCamelFilter);
190
191    tera.add_raw_template("contracts.ts.tera", CONTRACTS_TS_TEMPLATE)?;
192    tera.add_raw_template("hooks.ts.tera", HOOKS_TS_TEMPLATE)?;
193    tera.add_raw_template("debug_ui.tsx.tera", DEBUG_UI_TSX_TEMPLATE)?;
194
195    // Serialize ABIs and enrich each function arg with a `type_str` field —
196    // a simple lowercase Clarity type string (e.g. "uint128", "bool", "principal",
197    // "string-ascii", "string-utf8", "buff") used by the debug UI to build
198    // typed inputs and call toClarityValue() correctly.
199    let contracts_json: Vec<serde_json::Value> = abis
200        .iter()
201        .map(|c| {
202            let mut val = serde_json::to_value(c).expect("ContractAbi serialization failed");
203            if let Some(fns) = val["functions"].as_array_mut() {
204                for f in fns.iter_mut() {
205                    if let Some(args) = f["args"].as_array_mut() {
206                        for arg in args.iter_mut() {
207                            let type_str = clarity_type_str(&arg["type"]);
208                            arg["type_str"] = serde_json::Value::String(type_str);
209                        }
210                    }
211                }
212            }
213            val
214        })
215        .collect();
216
217    let ctx = tera::Context::from_serialize(serde_json::json!({
218        "contracts": contracts_json
219    }))?;
220
221    let mut written = 0;
222    written += write_if_changed(
223        out_dir.join("contracts.ts"),
224        &tera.render("contracts.ts.tera", &ctx)?,
225        quiet,
226    )?;
227    written += write_if_changed(
228        out_dir.join("hooks.ts"),
229        &tera.render("hooks.ts.tera", &ctx)?,
230        quiet,
231    )?;
232    written += write_if_changed(
233        out_dir.join("DebugContracts.tsx"),
234        &tera.render("debug_ui.tsx.tera", &ctx)?,
235        quiet,
236    )?;
237
238    Ok(written)
239}
240
241// ── Helpers ───────────────────────────────────────────────────────────────────
242
243/// Convert a serialized AbiType JSON value into a simple Clarity type string
244/// for use in the debug UI. e.g. uint128 → "uint128", string-ascii → "string-ascii"
245fn clarity_type_str(t: &serde_json::Value) -> String {
246    match t {
247        serde_json::Value::String(s) => s.clone(),
248        serde_json::Value::Object(map) => {
249            if map.contains_key("string-ascii") {
250                return "string-ascii".into();
251            }
252            if map.contains_key("string-utf8") {
253                return "string-utf8".into();
254            }
255            if map.contains_key("buffer") {
256                return "buff".into();
257            }
258            if map.contains_key("buff") {
259                return "buff".into();
260            }
261            if map.contains_key("list") {
262                return "list".into();
263            }
264            if map.contains_key("tuple") {
265                return "tuple".into();
266            }
267            if map.contains_key("optional") {
268                return "optional".into();
269            }
270            if map.contains_key("response") {
271                return "response".into();
272            }
273            "unknown".into()
274        }
275        _ => "unknown".into(),
276    }
277}
278
279fn hash_bytes(bytes: &[u8]) -> Vec<u8> {
280    let mut hasher = Sha256::new();
281    hasher.update(bytes);
282    hasher.finalize().to_vec()
283}
284
285fn find_stale_deployments(abis: &[ContractAbi], out_dir: &Path) -> Vec<String> {
286    let deployments_path = out_dir.join("deployments.json");
287    let Ok(raw) = std::fs::read_to_string(&deployments_path) else {
288        return vec![]; // no deployments file — nothing to compare
289    };
290    let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
291        return vec![];
292    };
293    stale_contract_names(abis, &json)
294}
295
296/// Contracts that were previously deployed under a *different* on-chain name
297/// (e.g. version bump `counter` → `counter-v2`). Undeployed / empty deployments
298/// are not stale — they just have not been deployed yet.
299fn stale_contract_names(abis: &[ContractAbi], json: &serde_json::Value) -> Vec<String> {
300    let network = json["network"].as_str().unwrap_or("").trim();
301    let Some(deployed) = json["contracts"].as_object() else {
302        return vec![];
303    };
304    // Fresh scaffold / post-clean: not "out of sync".
305    if network.is_empty() || deployed.is_empty() {
306        return vec![];
307    }
308
309    abis.iter()
310        .filter_map(|abi| {
311            let entry = deployed.get(&abi.contract_name)?;
312            let deployed_id = entry["contract_id"].as_str().unwrap_or("").trim();
313            if deployed_id.is_empty() {
314                return None;
315            }
316            if deployment_id_matches(deployed_id, &abi.contract_name) {
317                None
318            } else {
319                Some(abi.contract_name.clone())
320            }
321        })
322        .collect()
323}
324
325/// `ST….counter` or bare `counter` matches local name `counter`.
326fn deployment_id_matches(deployed_id: &str, contract_name: &str) -> bool {
327    match deployed_id.rsplit_once('.') {
328        Some((_, name)) => name == contract_name,
329        None => deployed_id == contract_name,
330    }
331}
332
333/// Write file only if content changed. Returns 1 if written, 0 if skipped.
334fn write_if_changed(path: PathBuf, contents: &str, quiet: bool) -> Result<usize> {
335    let new_bytes = contents.as_bytes();
336    let new_hash = hash_bytes(new_bytes);
337
338    if let Ok(existing) = fs::read(&path) {
339        if hash_bytes(&existing) == new_hash {
340            return Ok(0);
341        }
342    }
343
344    if let Some(parent) = path.parent() {
345        fs::create_dir_all(parent)?;
346    }
347    let mut file = fs::File::create(&path)?;
348    file.write_all(new_bytes)?;
349    if !quiet {
350        println!("[generated] {}", path.display());
351    }
352    Ok(1)
353}
354
355/// Print a prominent redeployment warning.
356fn warn_redeploy_required(stale: &[String], network: &str) {
357    let names = stale.join(", ");
358    eprintln!("\n{}", "━".repeat(60));
359    eprintln!("  ⚠  REDEPLOYMENT REQUIRED");
360    eprintln!("{}", "━".repeat(60));
361    eprintln!("  On-chain contract ids no longer match local names:");
362    eprintln!("  {}", names);
363    eprintln!();
364    eprintln!("  Clarity contracts are immutable. Redeploy so bindings");
365    eprintln!("  point at the current contract ids:");
366    eprintln!();
367    eprintln!("    stacksdapp deploy --network {network}");
368    eprintln!("    where network is either devnet/testnet/mainnet");
369    eprintln!();
370    eprintln!("  Until then, calls to renamed/versioned contracts will fail.");
371    eprintln!("{}\n", "━".repeat(60));
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use stacksdapp_parser::ContractAbi;
378
379    fn abi(name: &str) -> ContractAbi {
380        ContractAbi {
381            contract_id: format!(".{}", name),
382            contract_name: name.to_string(),
383            functions: vec![],
384            variables: vec![],
385            maps: vec![],
386            fungible_tokens: vec![],
387            non_fungible_tokens: vec![],
388        }
389    }
390
391    #[test]
392    fn empty_deployments_are_not_stale() {
393        let json = serde_json::json!({
394            "network": "",
395            "deployed_at": "",
396            "contracts": {}
397        });
398        let stale = stale_contract_names(&[abi("counter")], &json);
399        assert!(stale.is_empty(), "fresh project must not warn: {stale:?}");
400    }
401
402    #[test]
403    fn matching_deployment_is_not_stale() {
404        let json = serde_json::json!({
405            "network": "devnet",
406            "deployed_at": "2026-01-01T00:00:00Z",
407            "contracts": {
408                "counter": {
409                    "contract_id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter",
410                    "tx_id": "0xabc",
411                    "block_height": 1
412                }
413            }
414        });
415        let stale = stale_contract_names(&[abi("counter")], &json);
416        assert!(stale.is_empty(), "in-sync deploy must not warn: {stale:?}");
417    }
418
419    #[test]
420    fn renamed_deployment_is_stale() {
421        let json = serde_json::json!({
422            "network": "devnet",
423            "deployed_at": "2026-01-01T00:00:00Z",
424            "contracts": {
425                "counter": {
426                    "contract_id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter-v2",
427                    "tx_id": "0xabc",
428                    "block_height": 1
429                }
430            }
431        });
432        let stale = stale_contract_names(&[abi("counter")], &json);
433        assert_eq!(stale, vec!["counter".to_string()]);
434    }
435
436    #[test]
437    fn undeployed_sibling_is_not_stale() {
438        // New local contract while others are deployed — not a rename mismatch.
439        let json = serde_json::json!({
440            "network": "devnet",
441            "contracts": {
442                "counter": {
443                    "contract_id": "ST1.counter",
444                    "tx_id": "0x1",
445                    "block_height": 1
446                }
447            }
448        });
449        let stale = stale_contract_names(&[abi("counter"), abi("hello-token")], &json);
450        assert!(
451            stale.is_empty(),
452            "missing entry is undeployed, not stale: {stale:?}"
453        );
454    }
455
456    #[test]
457    fn deployment_id_match_is_exact_suffix() {
458        assert!(deployment_id_matches("ST1.counter", "counter"));
459        assert!(!deployment_id_matches("ST1.my-counter", "counter"));
460        assert!(deployment_id_matches("counter", "counter"));
461    }
462
463    #[test]
464    fn render_writes_empty_generated_stubs() {
465        let tmp = tempfile::tempdir().unwrap();
466        let written = render(&[], tmp.path()).unwrap();
467        assert_eq!(written, 3);
468        assert!(tmp.path().join("contracts.ts").is_file());
469        assert!(tmp.path().join("hooks.ts").is_file());
470        assert!(tmp.path().join("DebugContracts.tsx").is_file());
471    }
472}