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 stacks-dapp 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 status = tokio::process::Command::new("npm")
89 .args([
90 "install",
91 "--no-audit",
92 "--no-fund",
93 "--prefer-offline",
94 "--progress=false",
95 "--loglevel=error",
96 ])
97 .current_dir(&frontend_dir)
98 .status()
99 .await?;
100 if !status.success() {
101 anyhow::bail!("npm install in frontend/ failed.");
102 }
103 }
104
105 println!("[generate] Parsing contract ABIs...");
106 let abis = stacksdapp_parser::parse_project(&contracts_dir).await?;
107
108 if abis.is_empty() {
109 println!("[generate] No user contracts found in Clarinet.toml — nothing to generate.");
110 return Ok(());
111 }
112
113 println!(
114 "[generate] Found {} contract(s): {}",
115 abis.len(),
116 abis.iter()
117 .map(|a| a.contract_name.as_str())
118 .collect::<Vec<_>>()
119 .join(", ")
120 );
121
122 let out_dir = project_root.join("frontend/src/generated");
123 tokio::fs::create_dir_all(&out_dir).await?;
124
125 let deployments_path = out_dir.join("deployments.json");
129 if !deployments_path.exists() {
130 tokio::fs::write(
131 &deployments_path,
132 r#"{ "network": "", "deployed_at": "", "contracts": {} }"#,
133 )
134 .await?;
135 println!("[generate] Created empty deployments.json (run stacks-dapp deploy to populate)");
136 }
137
138 let written = render(&abis, &out_dir)?;
139
140 if written == 0 {
141 println!("[generate] All files already up to date.");
142 } else {
143 println!("[generate] Done — {written} file(s) written.");
144 }
145
146 let network = std::env::var("NEXT_PUBLIC_NETWORK").unwrap_or_else(|_| "<network>".into());
147 let stale = find_stale_deployments(&abis, &out_dir);
148 if !stale.is_empty() {
149 warn_redeploy_required(&stale, &network);
150 }
151
152 Ok(())
153}
154
155pub fn render(abis: &[ContractAbi], out_dir: &Path) -> Result<usize> {
157 let mut tera = Tera::default();
158 tera.register_filter("camel", CamelFilter);
159 tera.register_filter("upper_camel", UpperCamelFilter);
160
161 tera.add_raw_template("contracts.ts.tera", CONTRACTS_TS_TEMPLATE)?;
162 tera.add_raw_template("hooks.ts.tera", HOOKS_TS_TEMPLATE)?;
163 tera.add_raw_template("debug_ui.tsx.tera", DEBUG_UI_TSX_TEMPLATE)?;
164
165 let contracts_json: Vec<serde_json::Value> = abis
170 .iter()
171 .map(|c| {
172 let mut val = serde_json::to_value(c).expect("ContractAbi serialization failed");
173 if let Some(fns) = val["functions"].as_array_mut() {
174 for f in fns.iter_mut() {
175 if let Some(args) = f["args"].as_array_mut() {
176 for arg in args.iter_mut() {
177 let type_str = clarity_type_str(&arg["type"]);
178 arg["type_str"] = serde_json::Value::String(type_str);
179 }
180 }
181 }
182 }
183 val
184 })
185 .collect();
186
187 let ctx = tera::Context::from_serialize(serde_json::json!({
188 "contracts": contracts_json
189 }))?;
190
191 let mut written = 0;
192 written += write_if_changed(
193 out_dir.join("contracts.ts"),
194 &tera.render("contracts.ts.tera", &ctx)?,
195 )?;
196 written += write_if_changed(
197 out_dir.join("hooks.ts"),
198 &tera.render("hooks.ts.tera", &ctx)?,
199 )?;
200 written += write_if_changed(
201 out_dir.join("DebugContracts.tsx"),
202 &tera.render("debug_ui.tsx.tera", &ctx)?,
203 )?;
204
205 Ok(written)
206}
207
208fn clarity_type_str(t: &serde_json::Value) -> String {
213 match t {
214 serde_json::Value::String(s) => s.clone(),
215 serde_json::Value::Object(map) => {
216 if map.contains_key("string-ascii") {
217 return "string-ascii".into();
218 }
219 if map.contains_key("string-utf8") {
220 return "string-utf8".into();
221 }
222 if map.contains_key("buffer") {
223 return "buff".into();
224 }
225 if map.contains_key("buff") {
226 return "buff".into();
227 }
228 if map.contains_key("list") {
229 return "list".into();
230 }
231 if map.contains_key("tuple") {
232 return "tuple".into();
233 }
234 if map.contains_key("optional") {
235 return "optional".into();
236 }
237 if map.contains_key("response") {
238 return "response".into();
239 }
240 "unknown".into()
241 }
242 _ => "unknown".into(),
243 }
244}
245
246fn hash_bytes(bytes: &[u8]) -> Vec<u8> {
247 let mut hasher = Sha256::new();
248 hasher.update(bytes);
249 hasher.finalize().to_vec()
250}
251
252fn find_stale_deployments(abis: &[ContractAbi], out_dir: &Path) -> Vec<String> {
253 let deployments_path = out_dir.join("deployments.json");
254 let Ok(raw) = std::fs::read_to_string(&deployments_path) else {
255 return vec![]; };
257 let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
258 return vec![];
259 };
260
261 let deployed = json["contracts"].as_object();
262
263 abis.iter()
264 .filter(|abi| {
265 match deployed.and_then(|d| d.get(&abi.contract_name)) {
266 None => true, Some(entry) => {
268 let deployed_id = entry["contract_id"].as_str().unwrap_or("");
269 !deployed_id.ends_with(&format!(".{}", abi.contract_name))
272 }
273 }
274 })
275 .map(|abi| abi.contract_name.clone())
276 .collect()
277}
278
279fn write_if_changed(path: PathBuf, contents: &str) -> Result<usize> {
281 let new_bytes = contents.as_bytes();
282 let new_hash = hash_bytes(new_bytes);
283
284 if let Ok(existing) = fs::read(&path) {
285 if hash_bytes(&existing) == new_hash {
286 return Ok(0);
287 }
288 }
289
290 if let Some(parent) = path.parent() {
291 fs::create_dir_all(parent)?;
292 }
293 let mut file = fs::File::create(&path)?;
294 file.write_all(new_bytes)?;
295 println!("[generated] {}", path.display());
296 Ok(1)
297}
298
299fn warn_redeploy_required(stale: &[String], network: &str) {
301 let names = stale.join(", ");
302 eprintln!("\n{}", "━".repeat(60));
303 eprintln!(" ⚠ REDEPLOYMENT REQUIRED");
304 eprintln!("{}", "━".repeat(60));
305 eprintln!(" Contracts on-chain are out of sync with local source:");
306 eprintln!(" {}", names);
307 eprintln!();
308 eprintln!(" Clarity contracts are immutable. Your changes won't take");
309 eprintln!(" effect until you redeploy:");
310 eprintln!();
311 eprintln!(" stacksdapp deploy --network {network}");
312 eprintln!(" where network is either devnet/testnet/mainnet");
313 eprintln!();
314 eprintln!(" Until then, calls to new/changed functions will fail.");
315 eprintln!("{}\n", "━".repeat(60));
316}