Skip to main content

stacksdapp_codegen/
lib.rs

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