stacksdapp_codegen/
lib.rs1use 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
14fn 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
63pub 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 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
133pub 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 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
177fn 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![]; };
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, Some(entry) => {
221 let deployed_id = entry["contract_id"].as_str().unwrap_or("");
222 !deployed_id.ends_with(&format!(".{}", abi.contract_name))
225 }
226 }
227 })
228 .map(|abi| abi.contract_name.clone())
229 .collect()
230}
231
232fn 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
252fn 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}