stacksdapp_codegen/
lib.rs1use 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
23fn 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
72pub 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 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
160pub 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 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
213fn 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![]; };
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, Some(entry) => {
273 let deployed_id = entry["contract_id"].as_str().unwrap_or("");
274 !deployed_id.ends_with(&format!(".{}", abi.contract_name))
277 }
278 }
279 })
280 .map(|abi| abi.contract_name.clone())
281 .collect()
282}
283
284fn 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
304fn 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}