1use crate::spec::{ContractFunction, ContractSpec, ContractType};
18use std::fmt::Write as _;
19
20#[derive(Debug, thiserror::Error, PartialEq, Eq)]
22pub enum ClientGenError {
23 #[error(
25 "unsupported type '{type_name}' ({where_}) in function `{function}` — \
26 the generator core supports u32|i32|u64|i64|bool|address|string|symbol|void only"
27 )]
28 UnsupportedType {
29 function: String,
30 type_name: String,
31 where_: String,
32 },
33}
34
35fn map_scalar(t: &ContractType) -> Option<&'static str> {
38 if t.kind != "primitive" {
39 return None;
40 }
41 match t.name.as_str() {
42 "u32" => Some("u32"),
43 "i32" => Some("i32"),
44 "u64" => Some("u64"),
45 "i64" => Some("i64"),
46 "bool" => Some("bool"),
47 "address" | "string" | "symbol" => Some("String"),
48 "void" => Some("()"),
49 _ => None,
50 }
51}
52
53fn scalar_type_tag(t: &ContractType) -> Option<&'static str> {
55 match t.name.as_str() {
56 "u32" => Some("u32"),
57 "i32" => Some("i32"),
58 "u64" => Some("u64"),
59 "i64" => Some("i64"),
60 "bool" => Some("bool"),
61 "address" => Some("address"),
62 "string" => Some("string"),
63 "symbol" => Some("symbol"),
64 _ => None,
65 }
66}
67
68fn rust_ident(name: &str) -> String {
70 let mut out = String::with_capacity(name.len());
71 for ch in name.chars() {
72 if ch.is_ascii_alphanumeric() || ch == '_' {
73 out.extend(ch.to_lowercase());
74 } else {
75 out.push('_');
76 }
77 }
78 if out.is_empty() || out.as_bytes()[0].is_ascii_digit() {
79 out.insert(0, '_');
80 }
81 out
82}
83
84fn struct_ident(fn_name: &str) -> String {
86 let mut out = String::new();
87 let mut upper_next = true;
88 for ch in fn_name.chars() {
89 if ch == '_' || !ch.is_ascii_alphanumeric() {
90 upper_next = true;
91 continue;
92 }
93 if upper_next {
94 out.extend(ch.to_uppercase());
95 } else {
96 out.extend(ch.to_lowercase());
97 }
98 upper_next = false;
99 }
100 out.push_str("Call");
101 out
102}
103
104fn check_supported(f: &ContractFunction) -> Result<(), ClientGenError> {
106 for p in &f.parameters {
107 if map_scalar(&p.type_).is_none() {
108 return Err(ClientGenError::UnsupportedType {
109 function: f.name.clone(),
110 type_name: format!("{}:{}", p.type_.kind, p.type_.name),
111 where_: format!("parameter `{}`", p.name),
112 });
113 }
114 }
115 for o in &f.outputs {
116 if map_scalar(o).is_none() {
117 return Err(ClientGenError::UnsupportedType {
118 function: f.name.clone(),
119 type_name: format!("{}:{}", o.kind, o.name),
120 where_: "return value".to_string(),
121 });
122 }
123 }
124 if f.outputs.len() > 1 {
125 return Err(ClientGenError::UnsupportedType {
126 function: f.name.clone(),
127 type_name: format!("multiple returns ({})", f.outputs.len()),
128 where_: "return value".to_string(),
129 });
130 }
131 Ok(())
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct GenerateOptions {
137 pub skip_unsupported: bool,
140}
141
142pub fn generate_client(spec: &ContractSpec) -> Result<String, ClientGenError> {
144 generate_client_with_options(spec, &GenerateOptions::default())
145}
146
147pub fn generate_client_with_options(
149 spec: &ContractSpec,
150 options: &GenerateOptions,
151) -> Result<String, ClientGenError> {
152 let mut supported = Vec::new();
153 let mut skipped = Vec::new();
154
155 for f in &spec.functions {
156 match check_supported(f) {
157 Ok(()) => supported.push(f),
158 Err(err) => {
159 if options.skip_unsupported {
160 skipped.push((f, err));
161 } else {
162 return Err(err);
163 }
164 }
165 }
166 }
167
168 let mut out = String::new();
169 out.push_str("// Generated by `sdkt generate client`. Do not edit by hand.\n");
170 out.push_str("//\n");
171 out.push_str("// Each `*Call` struct encodes one contract function as `TYPE:VALUE`\n");
172 out.push_str("// argument strings accepted by `sdkt call` and `sdkt invoke`.\n");
173
174 if !skipped.is_empty() {
175 out.push_str("//\n");
176 out.push_str("// Skipped unsupported functions:\n");
177 for (f, err) in &skipped {
178 let _ = writeln!(out, "// - {}: {}", f.name, err);
179 }
180 }
181 out.push('\n');
182
183 if spec.functions.is_empty() {
184 out.push_str("/// This contract exposes no callable functions.\n");
185 out.push_str("pub fn contract_functions() -> &'static [&'static str] { &[] }\n");
186 return Ok(out);
187 }
188
189 if supported.is_empty() {
190 out.push_str(
191 "/// No callable functions were generated (all functions use unsupported types).\n",
192 );
193 out.push_str("pub fn contract_functions() -> &'static [&'static str] { &[] }\n");
194 return Ok(out);
195 }
196
197 out.push_str("/// Names of all generated contract calls, in spec order.\n");
198 out.push_str("pub fn contract_functions() -> &'static [&'static str] {\n &[");
199 for (i, f) in supported.iter().enumerate() {
200 if i > 0 {
201 out.push_str(", ");
202 }
203 let _ = write!(out, "\"{}\"", f.name);
204 }
205 out.push_str("]\n}\n\n");
206
207 for f in &supported {
208 let struct_name = struct_ident(&f.name);
209 let out_ty = f
210 .outputs
211 .first()
212 .map(|o| map_scalar(o).unwrap_or("()"))
213 .unwrap_or("()");
214 let alias_name = format!("{}Output", struct_name.trim_end_matches("Call"));
215
216 if !f.doc.trim().is_empty() {
217 let _ = writeln!(out, "/// {} — {}", f.name, f.doc.trim().replace('\n', " "));
218 } else {
219 let _ = writeln!(out, "/// Typed call builder for `{}`.", f.name);
220 }
221 let _ = writeln!(out, "pub struct {} {{", struct_name);
222 for p in &f.parameters {
223 let rust_ty = map_scalar(&p.type_).expect("validated above");
224 let field = rust_ident(&p.name);
225 if !p.doc.trim().is_empty() {
226 let _ = writeln!(
227 out,
228 " /// {} — {}",
229 p.name,
230 p.doc.trim().replace('\n', " ")
231 );
232 }
233 let _ = writeln!(out, " pub {}: {},", field, rust_ty);
234 }
235 let _ = writeln!(out, "}}");
236
237 let _ = writeln!(out, "#[allow(dead_code)]");
238 let _ = writeln!(out, "impl {} {{", struct_name);
239 let _ = writeln!(out, " /// Contract function name.");
240 let _ = writeln!(out, " pub const NAME: &'static str = \"{}\";", f.name);
241 let _ = writeln!(
242 out,
243 " /// Encode this call as `TYPE:VALUE` argument strings"
244 );
245 let _ = writeln!(
246 out,
247 " /// for `sdkt call <contract-id> {} [args...]`.",
248 f.name
249 );
250 let _ = writeln!(out, " pub fn args(&self) -> Vec<String> {{");
251 let _ = writeln!(out, " vec![");
252 for p in &f.parameters {
253 let tag = scalar_type_tag(&p.type_).expect("validated above");
254 let field = rust_ident(&p.name);
255 let expr = if p.type_.name == "bool" || p.type_.name.starts_with(['u', 'i']) {
256 format!("self.{}.to_string()", field)
257 } else {
258 format!("self.{}.clone()", field)
259 };
260 let _ = writeln!(out, " format!(\"{}:{{}}\", {}),", tag, expr);
261 }
262 let _ = writeln!(out, " ]");
263 let _ = writeln!(out, " }}");
264 let _ = writeln!(out, "}}");
265 let _ = writeln!(
268 out,
269 "/// Expected return type of `{}`.\npub type {} = {};\n",
270 f.name, alias_name, out_ty
271 );
272 }
273
274 Ok(out)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::spec::ContractParameter;
281
282 fn param(name: &str, type_name: &str, kind: &str) -> ContractParameter {
283 ContractParameter {
284 name: name.into(),
285 doc: String::new(),
286 type_: ContractType {
287 name: type_name.into(),
288 kind: kind.into(),
289 doc: String::new(),
290 members: vec![],
291 type_args: vec![],
292 bytes_n: None,
293 },
294 }
295 }
296
297 fn ty(name: &str, kind: &str) -> ContractType {
298 ContractType {
299 name: name.into(),
300 kind: kind.into(),
301 doc: String::new(),
302 members: vec![],
303 type_args: vec![],
304 bytes_n: None,
305 }
306 }
307
308 fn func(
309 name: &str,
310 params: Vec<ContractParameter>,
311 outputs: Vec<ContractType>,
312 ) -> ContractFunction {
313 ContractFunction {
314 name: name.into(),
315 doc: String::new(),
316 parameters: params,
317 outputs,
318 }
319 }
320
321 fn spec_of(functions: Vec<ContractFunction>) -> ContractSpec {
322 ContractSpec {
323 env_meta: None,
324 functions,
325 custom_types: vec![],
326 events: vec![],
327 }
328 }
329
330 #[test]
331 fn generates_empty_contract() {
332 let code = generate_client(&spec_of(vec![])).unwrap();
333 assert!(code.contains("no callable functions"));
334 assert!(code.contains("pub fn contract_functions() -> &'static [&'static str] { &[] }"));
335 }
336
337 #[test]
338 fn generates_supported_scalars() {
339 let spec = spec_of(vec![func(
340 "transfer",
341 vec![
342 param("to", "address", "primitive"),
343 param("amount", "u64", "primitive"),
344 param("flag", "bool", "primitive"),
345 ],
346 vec![ty("void", "primitive")],
347 )]);
348 let code = generate_client(&spec).unwrap();
349 assert!(code.contains("pub struct TransferCall {"));
350 assert!(code.contains("pub to: String,"));
351 assert!(code.contains("pub amount: u64,"));
352 assert!(code.contains("pub flag: bool,"));
353 assert!(code.contains("pub const NAME: &'static str = \"transfer\";"));
354 assert!(code.contains("format!(\"address:{}\", self.to.clone())"));
355 assert!(code.contains("format!(\"u64:{}\", self.amount.to_string())"));
356 assert!(code.contains("format!(\"bool:{}\", self.flag.to_string())"));
357 assert!(code.contains("pub type TransferOutput = ();"));
358 }
359
360 #[test]
361 fn output_is_deterministic() {
362 let spec = spec_of(vec![
363 func("mint", vec![param("amt", "u32", "primitive")], vec![]),
364 func("burn", vec![param("amt", "u32", "primitive")], vec![]),
365 ]);
366 let a = generate_client(&spec).unwrap();
367 let b = generate_client(&spec).unwrap();
368 assert_eq!(a, b, "generation must be byte-identical across runs");
369 }
370
371 #[test]
372 fn rejects_udt_parameter() {
373 let spec = spec_of(vec![func(
374 "store",
375 vec![param("p", "Point", "udt")],
376 vec![],
377 )]);
378 let err = generate_client(&spec).unwrap_err();
379 match err {
380 ClientGenError::UnsupportedType {
381 function,
382 type_name,
383 where_,
384 } => {
385 assert_eq!(function, "store");
386 assert!(type_name.contains("Point"));
387 assert!(where_.contains("parameter"));
388 }
389 }
390 }
391
392 #[test]
393 fn rejects_compound_return() {
394 let spec = spec_of(vec![func("get", vec![], vec![ty("option", "compound")])]);
395 let err = generate_client(&spec).unwrap_err();
396 assert!(matches!(err, ClientGenError::UnsupportedType { .. }));
397 assert!(err.to_string().contains("return value"));
398 }
399
400 #[test]
401 fn rejects_multiple_returns() {
402 let spec = spec_of(vec![func(
403 "multi",
404 vec![],
405 vec![ty("u32", "primitive"), ty("u32", "primitive")],
406 )]);
407 let err = generate_client(&spec).unwrap_err();
408 assert!(err.to_string().contains("multiple returns"));
409 }
410
411 #[test]
412 fn no_partial_output_on_failure() {
413 let spec = spec_of(vec![
414 func("ok", vec![param("x", "u32", "primitive")], vec![]),
415 func("bad", vec![param("p", "Point", "udt")], vec![]),
416 ]);
417 let res = generate_client(&spec);
418 assert!(res.is_err());
419 }
420
421 #[test]
422 fn identifiers_are_sanitized_and_valid() {
423 let spec = spec_of(vec![func(
424 "increment",
425 vec![param("New", "u64", "primitive")],
426 vec![ty("u64", "primitive")],
427 )]);
428 let code = generate_client(&spec).unwrap();
429 assert!(code.contains("pub struct IncrementCall"));
430 assert!(code.contains("pub type IncrementOutput = u64;"));
431 assert!(code.contains("pub new: u64,"));
433 }
434
435 #[test]
436 fn string_and_symbol_map_to_string() {
437 let spec = spec_of(vec![func(
438 "greet",
439 vec![
440 param("name", "string", "primitive"),
441 param("tag", "symbol", "primitive"),
442 ],
443 vec![],
444 )]);
445 let code = generate_client(&spec).unwrap();
446 assert!(code.contains("pub name: String,"));
447 assert!(code.contains("pub tag: String,"));
448 assert!(code.contains("format!(\"string:{}\", self.name.clone())"));
449 assert!(code.contains("format!(\"symbol:{}\", self.tag.clone())"));
450 }
451
452 #[test]
453 fn i32_i64_supported() {
454 let spec = spec_of(vec![func(
455 "set",
456 vec![
457 param("a", "i32", "primitive"),
458 param("b", "i64", "primitive"),
459 ],
460 vec![ty("bool", "primitive")],
461 )]);
462 let code = generate_client(&spec).unwrap();
463 assert!(code.contains("pub a: i32,"));
464 assert!(code.contains("pub b: i64,"));
465 assert!(code.contains("format!(\"i64:{}\", self.b.to_string())"));
466 assert!(code.contains("pub type SetOutput = bool;"));
467 }
468
469 #[test]
470 fn skip_unsupported_emits_supported_functions_and_header() {
471 let spec = spec_of(vec![
472 func(
473 "hello",
474 vec![param("x", "u32", "primitive")],
475 vec![ty("u32", "primitive")],
476 ),
477 func(
478 "batch",
479 vec![param("transfers", "Map", "compound")],
480 vec![ty("u32", "primitive")],
481 ),
482 func(
483 "increment",
484 vec![param("by", "u32", "primitive")],
485 vec![ty("u32", "primitive")],
486 ),
487 ]);
488
489 let opts = GenerateOptions {
490 skip_unsupported: true,
491 };
492 let code = generate_client_with_options(&spec, &opts).unwrap();
493
494 assert!(code.contains("pub struct HelloCall"));
496 assert!(code.contains("pub struct IncrementCall"));
497 assert!(!code.contains("pub struct BatchCall"));
499
500 assert!(code.contains("// Skipped unsupported functions:"));
502 assert!(code.contains("// - batch: unsupported type 'compound:Map' (parameter `transfers`) in function `batch`"));
503
504 assert!(code.contains("pub fn contract_functions() -> &'static [&'static str] {\n &[\"hello\", \"increment\"]\n}"));
506 }
507
508 #[test]
509 fn skip_unsupported_without_flag_still_aborts() {
510 let spec = spec_of(vec![
511 func(
512 "hello",
513 vec![param("x", "u32", "primitive")],
514 vec![ty("u32", "primitive")],
515 ),
516 func(
517 "batch",
518 vec![param("transfers", "Map", "compound")],
519 vec![ty("u32", "primitive")],
520 ),
521 ]);
522 let err = generate_client(&spec).unwrap_err();
523 assert!(matches!(err, ClientGenError::UnsupportedType { .. }));
524 }
525
526 #[test]
527 fn skip_unsupported_all_functions_unsupported() {
528 let spec = spec_of(vec![
529 func("bad1", vec![param("p", "Point", "udt")], vec![]),
530 func("bad2", vec![], vec![ty("vec", "compound")]),
531 ]);
532
533 let opts = GenerateOptions {
534 skip_unsupported: true,
535 };
536 let code = generate_client_with_options(&spec, &opts).unwrap();
537
538 assert!(code.contains("// Skipped unsupported functions:"));
539 assert!(code.contains("// - bad1:"));
540 assert!(code.contains("// - bad2:"));
541 assert!(code.contains("No callable functions were generated"));
542 assert!(code.contains("pub fn contract_functions() -> &'static [&'static str] { &[] }"));
543 }
544
545 #[test]
546 fn skip_unsupported_with_no_unsupported_is_byte_identical_to_default() {
547 let spec = spec_of(vec![
548 func(
549 "foo",
550 vec![param("a", "u32", "primitive")],
551 vec![ty("bool", "primitive")],
552 ),
553 func("bar", vec![param("b", "string", "primitive")], vec![]),
554 ]);
555
556 let default_code = generate_client(&spec).unwrap();
557 let opts = GenerateOptions {
558 skip_unsupported: true,
559 };
560 let skip_code = generate_client_with_options(&spec, &opts).unwrap();
561
562 assert_eq!(
563 default_code, skip_code,
564 "output must be identical when nothing is skipped"
565 );
566 assert!(!skip_code.contains("Skipped"));
567 }
568
569 #[test]
570 fn skip_unsupported_preserves_spec_order() {
571 let spec = spec_of(vec![
572 func("first", vec![param("a", "u32", "primitive")], vec![]),
573 func("skip1", vec![param("b", "Vec", "compound")], vec![]),
574 func("second", vec![param("c", "u64", "primitive")], vec![]),
575 func("skip2", vec![param("d", "Map", "compound")], vec![]),
576 func("third", vec![param("e", "bool", "primitive")], vec![]),
577 ]);
578
579 let opts = GenerateOptions {
580 skip_unsupported: true,
581 };
582 let code = generate_client_with_options(&spec, &opts).unwrap();
583
584 assert!(code.contains("&[\"first\", \"second\", \"third\"]"));
585 }
586}