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
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 generate_all_impl(false).await
76}
77
78pub 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 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
181pub 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 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
241fn 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![]; };
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
296fn 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 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
325fn 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
333fn 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
355fn 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 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}