Skip to main content

sdkt_wasm/
client_gen.rs

1//! Minimal typed Rust client generation from a parsed [`ContractSpec`].
2//!
3//! `generate_client` turns the *supported subset* of a contract interface into
4//! deterministic, dependency-free Rust source. Each function becomes a
5//! `*Call` struct carrying its name, typed parameters, and an `args()` method
6//! that encodes the call as `TYPE:VALUE` strings — the exact convention
7//! [`sdkt call` / `sdkt invoke`] already accept.
8//!
9//! Scope is intentionally narrow (maintainer core):
10//! - Supported parameter/return types: the primitive scalar subset listed in
11//!   [`map_scalar`].
12//! - Everything else (UDT, Option, Result, Vec, Map, Tuple, BytesN, Val,
13//!   unclassified primitives) fails with a clear
14//!   [`ClientGenError::UnsupportedType`].
15//! - No network access, no template system, no plugins.
16
17use crate::spec::{ContractFunction, ContractSpec, ContractType};
18use std::fmt::Write as _;
19
20/// Errors that abort client generation.
21#[derive(Debug, thiserror::Error, PartialEq, Eq)]
22pub enum ClientGenError {
23    /// The interface references a type outside the supported subset.
24    #[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
35/// Map a supported primitive `ContractType` to its Rust representation, or
36/// `None` when the type is outside the core subset.
37fn 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
53/// Encode a supported scalar as the `TYPE:` prefix for `sdkt` typed args.
54fn 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
68/// Sanitize a spec name into a valid Rust identifier fragment.
69fn 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
84/// Pascal-case a spec name for the generated struct type.
85fn 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
104/// Validate a function against the supported subset.
105fn 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/// Options controlling client generation.
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct GenerateOptions {
137    /// When true, functions with unsupported parameter or return types
138    /// are omitted from the generated client instead of aborting generation.
139    pub skip_unsupported: bool,
140}
141
142/// Generate a deterministic Rust client module from a parsed ContractSpec using default options.
143pub fn generate_client(spec: &ContractSpec) -> Result<String, ClientGenError> {
144    generate_client_with_options(spec, &GenerateOptions::default())
145}
146
147/// Generate a deterministic Rust client module from a parsed ContractSpec with custom [`GenerateOptions`].
148pub 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        // Return type alias at module level (stable Rust — an associated type
266        // inside an inherent impl would require nightly).
267        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        // "New" -> "new" (lowercase, valid identifier)
432        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        // Supported builders present
495        assert!(code.contains("pub struct HelloCall"));
496        assert!(code.contains("pub struct IncrementCall"));
497        // Unsupported function skipped
498        assert!(!code.contains("pub struct BatchCall"));
499
500        // Header documents the skipped function and its detail
501        assert!(code.contains("// Skipped unsupported functions:"));
502        assert!(code.contains("// - batch: unsupported type 'compound:Map' (parameter `transfers`) in function `batch`"));
503
504        // contract_functions lists only supported functions in spec order
505        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}