Skip to main content

soroban_cli/commands/contract/
arg_parsing.rs

1use crate::commands::contract::arg_parsing::Error::HelpMessage;
2use crate::commands::contract::deploy::wasm::CONSTRUCTOR_FUNCTION_NAME;
3use crate::commands::txn_result::TxnResult;
4use crate::config::{self, sc_address, UnresolvedScAddress};
5use crate::print::Print;
6use crate::signer::{self, Signer};
7use crate::xdr::{
8    self, Hash, InvokeContractArgs, ScSpecEntry, ScSpecFunctionV0, ScSpecTypeDef, ScVal, ScVec,
9};
10use clap::error::ErrorKind::DisplayHelp;
11use clap::value_parser;
12use heck::ToKebabCase;
13use soroban_spec_tools::{sanitize, Spec};
14use std::collections::HashMap;
15use std::convert::TryInto;
16use std::env;
17use std::ffi::OsString;
18use std::fmt::Debug;
19use std::path::PathBuf;
20use stellar_xdr::curr::ContractId;
21
22#[derive(thiserror::Error, Debug)]
23pub enum Error {
24    #[error("Failed to parse argument '{arg}': {error}\n\nContext: Expected type {expected_type}, but received: '{received_value}'\n\nSuggestion: {suggestion}")]
25    CannotParseArg {
26        arg: String,
27        error: soroban_spec_tools::Error,
28        expected_type: String,
29        received_value: String,
30        suggestion: String,
31    },
32    #[error("Invalid JSON in argument '{arg}': {json_error}\n\nReceived value: '{received_value}'\n\nSuggestions:\n- Check for missing quotes around strings\n- Ensure proper JSON syntax (commas, brackets, etc.)\n- For complex objects, consider using --{arg}-file-path to load from a file")]
33    InvalidJsonArg {
34        arg: String,
35        json_error: String,
36        received_value: String,
37    },
38    #[error("Type mismatch for argument '{arg}': expected {expected_type}, but got {actual_type}\n\nReceived value: '{received_value}'\n\nSuggestions:\n- For {expected_type}, ensure the value is properly formatted\n- Check the contract specification for the correct argument type")]
39    TypeMismatch {
40        arg: String,
41        expected_type: String,
42        actual_type: String,
43        received_value: String,
44    },
45    #[error("Missing required argument '{arg}' of type {expected_type}\n\nSuggestions:\n- Add the argument: --{arg} <value>\n- Or use a file: --{arg}-file-path <path-to-json-file>\n- Check the contract specification for required arguments")]
46    MissingArgument { arg: String, expected_type: String },
47    #[error("Cannot read file {file_path:?}: {error}\n\nSuggestions:\n- Check if the file exists and is readable\n- Ensure the file path is correct\n- Verify file permissions")]
48    MissingFileArg { file_path: PathBuf, error: String },
49    #[error("cannot print result {result:?}: {error}")]
50    CannotPrintResult {
51        result: ScVal,
52        error: soroban_spec_tools::Error,
53    },
54    #[error("function '{function_name}' was not found in the contract\n\nAvailable functions: {available_functions}\n\nSuggestions:\n- Check the function name spelling\n- Use 'stellar contract invoke --help' to see available functions\n- Verify the contract ID is correct")]
55    FunctionNotFoundInContractSpec {
56        function_name: String,
57        available_functions: String,
58    },
59    #[error("function name '{function_name}' is too long (max 32 characters)\n\nReceived: {function_name} ({length} characters)")]
60    FunctionNameTooLong {
61        function_name: String,
62        length: usize,
63    },
64    #[error("argument count ({current}) surpasses maximum allowed count ({maximum})\n\nSuggestions:\n- Reduce the number of arguments\n- Consider using file-based arguments for complex data\n- Check if some arguments can be combined")]
65    MaxNumberOfArgumentsReached { current: usize, maximum: usize },
66    #[error("Unsupported address type '{address}'\n\nSupported formats:\n- Account addresses: G... (starts with G)\n- Contract addresses: C... (starts with C)\n- Muxed accounts: M... (starts with M)\n- Identity names: alice, bob, etc.\n\nReceived: '{address}'")]
67    UnsupportedScAddress { address: String },
68    #[error(transparent)]
69    Xdr(#[from] xdr::Error),
70    #[error(transparent)]
71    StrVal(#[from] soroban_spec_tools::Error),
72    #[error(transparent)]
73    ScAddress(#[from] sc_address::Error),
74    #[error(transparent)]
75    Config(#[from] config::Error),
76    #[error("")]
77    HelpMessage(String),
78    #[error(transparent)]
79    Signer(#[from] signer::Error),
80}
81
82pub type HostFunctionParameters = (String, Spec, InvokeContractArgs, Vec<Signer>);
83
84fn running_cmd() -> String {
85    let mut args: Vec<String> = env::args().collect();
86
87    if let Some(pos) = args.iter().position(|arg| arg == "--") {
88        args.truncate(pos);
89    }
90
91    format!("{} --", args.join(" "))
92}
93
94pub async fn build_host_function_parameters(
95    contract_id: &stellar_strkey::Contract,
96    slop: &[OsString],
97    spec_entries: &[ScSpecEntry],
98    config: &config::Args,
99) -> Result<HostFunctionParameters, Error> {
100    build_host_function_parameters_with_filter(contract_id, slop, spec_entries, config, true).await
101}
102
103pub async fn build_constructor_parameters(
104    contract_id: &stellar_strkey::Contract,
105    slop: &[OsString],
106    spec_entries: &[ScSpecEntry],
107    config: &config::Args,
108) -> Result<HostFunctionParameters, Error> {
109    build_host_function_parameters_with_filter(contract_id, slop, spec_entries, config, false).await
110}
111
112async fn build_host_function_parameters_with_filter(
113    contract_id: &stellar_strkey::Contract,
114    slop: &[OsString],
115    spec_entries: &[ScSpecEntry],
116    config: &config::Args,
117    filter_constructor: bool,
118) -> Result<HostFunctionParameters, Error> {
119    let spec = Spec(Some(spec_entries.to_vec()));
120    let cmd = build_clap_command(&spec, filter_constructor)?;
121    let (function, matches_) = parse_command_matches(cmd, slop)?;
122    let func = get_function_spec(&spec, &function)?;
123    let (parsed_args, signers) = parse_function_arguments(&func, &matches_, &spec, config).await?;
124    let invoke_args = build_invoke_contract_args(contract_id, &function, parsed_args)?;
125
126    Ok((function, spec, invoke_args, signers))
127}
128
129fn build_clap_command(spec: &Spec, filter_constructor: bool) -> Result<clap::Command, Error> {
130    let mut cmd = clap::Command::new(running_cmd())
131        .no_binary_name(true)
132        .term_width(300)
133        .max_term_width(300);
134
135    for ScSpecFunctionV0 { name, .. } in spec.find_functions()? {
136        let function_name = name.to_utf8_string_lossy();
137        // Filter out the constructor function from the invoke command
138        if !filter_constructor || function_name != CONSTRUCTOR_FUNCTION_NAME {
139            cmd = cmd.subcommand(build_custom_cmd(&function_name, spec)?);
140        }
141    }
142    cmd.build();
143    Ok(cmd)
144}
145
146fn parse_command_matches(
147    mut cmd: clap::Command,
148    slop: &[OsString],
149) -> Result<(String, clap::ArgMatches), Error> {
150    let long_help = cmd.render_long_help();
151    let maybe_matches = cmd.try_get_matches_from(slop);
152
153    let Some((function, matches_)) = (match maybe_matches {
154        Ok(mut matches) => matches.remove_subcommand(),
155        Err(e) => {
156            if e.kind() == DisplayHelp {
157                return Err(HelpMessage(e.to_string()));
158            }
159            e.exit();
160        }
161    }) else {
162        return Err(HelpMessage(format!("{long_help}")));
163    };
164
165    Ok((function.clone(), matches_))
166}
167
168fn get_function_spec(spec: &Spec, function: &str) -> Result<ScSpecFunctionV0, Error> {
169    // Exact match (normal path).
170    if let Ok(f) = spec.find_function(function) {
171        return Ok(f.clone());
172    }
173    // Fallback: match against sanitized names for functions whose names contain
174    // control characters (clap registers the sanitized form as the command name).
175    if let Ok(functions) = spec.find_functions() {
176        for f in functions {
177            if sanitize(&f.name.to_utf8_string_lossy()) == function {
178                return Ok(f.clone());
179            }
180        }
181    }
182    Err(Error::FunctionNotFoundInContractSpec {
183        function_name: function.to_string(),
184        available_functions: get_available_functions(spec),
185    })
186}
187
188async fn parse_function_arguments(
189    func: &ScSpecFunctionV0,
190    matches_: &clap::ArgMatches,
191    spec: &Spec,
192    config: &config::Args,
193) -> Result<(Vec<ScVal>, Vec<Signer>), Error> {
194    let mut parsed_args = Vec::with_capacity(func.inputs.len());
195    let mut signers = Vec::<Signer>::new();
196
197    for i in func.inputs.iter() {
198        parse_single_argument(i, matches_, spec, config, &mut signers, &mut parsed_args).await?;
199    }
200
201    Ok((parsed_args, signers))
202}
203
204async fn parse_single_argument(
205    input: &stellar_xdr::curr::ScSpecFunctionInputV0,
206    matches_: &clap::ArgMatches,
207    spec: &Spec,
208    config: &config::Args,
209    signers: &mut Vec<Signer>,
210    parsed_args: &mut Vec<ScVal>,
211) -> Result<(), Error> {
212    let name = sanitize(&input.name.to_utf8_string_lossy());
213    let expected_type_name = get_type_name(&input.type_); //-0--
214
215    if let Some(mut val) = matches_.get_raw(&name) {
216        let s = match val.next() {
217            Some(v) => v.to_string_lossy().to_string(),
218            None => {
219                return Err(Error::MissingArgument {
220                    arg: name.clone(),
221                    expected_type: expected_type_name,
222                });
223            }
224        };
225
226        // Handle address types with signer resolution
227        if matches!(
228            input.type_,
229            ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress
230        ) {
231            let trimmed_s = s.trim_matches('"');
232            let addr = resolve_address(trimmed_s, config)?;
233            if let Some(signer) = resolve_signer(trimmed_s, config).await {
234                signers.push(signer);
235            }
236            parsed_args.push(parse_argument_with_validation(
237                &name,
238                &addr,
239                &input.type_,
240                spec,
241                config,
242            )?);
243            return Ok(());
244        }
245
246        parsed_args.push(parse_argument_with_validation(
247            &name,
248            &s,
249            &input.type_,
250            spec,
251            config,
252        )?);
253        Ok(())
254    } else if matches!(input.type_, ScSpecTypeDef::Option(_)) {
255        parsed_args.push(ScVal::Void);
256        Ok(())
257    } else if let Some(arg_path) = matches_.get_one::<PathBuf>(&fmt_arg_file_name(&name)) {
258        parsed_args.push(parse_file_argument(
259            &name,
260            arg_path,
261            &input.type_,
262            expected_type_name,
263            spec,
264            config,
265        )?);
266        Ok(())
267    } else {
268        Err(Error::MissingArgument {
269            arg: name,
270            expected_type: expected_type_name,
271        })
272    }
273}
274
275fn parse_file_argument(
276    name: &str,
277    arg_path: &PathBuf,
278    type_def: &ScSpecTypeDef,
279    expected_type_name: String,
280    spec: &Spec,
281    config: &config::Args,
282) -> Result<ScVal, Error> {
283    if matches!(type_def, ScSpecTypeDef::Bytes | ScSpecTypeDef::BytesN(_)) {
284        let bytes = std::fs::read(arg_path).map_err(|e| Error::MissingFileArg {
285            file_path: arg_path.clone(),
286            error: e.to_string(),
287        })?;
288        ScVal::try_from(&bytes).map_err(|()| Error::CannotParseArg {
289            arg: name.to_string(),
290            error: soroban_spec_tools::Error::Unknown,
291            expected_type: expected_type_name,
292            received_value: format!("{} bytes from file", bytes.len()),
293            suggestion: "Ensure the file contains valid binary data for the expected byte type"
294                .to_string(),
295        })
296    } else {
297        let file_contents =
298            std::fs::read_to_string(arg_path).map_err(|e| Error::MissingFileArg {
299                file_path: arg_path.clone(),
300                error: e.to_string(),
301            })?;
302        tracing::debug!(
303            "file {arg_path:?}, has contents:\n{file_contents}\nAnd type {:#?}\n{}",
304            type_def,
305            file_contents.len()
306        );
307        parse_argument_with_validation(name, &file_contents, type_def, spec, config)
308    }
309}
310
311fn build_invoke_contract_args(
312    contract_id: &stellar_strkey::Contract,
313    function: &str,
314    parsed_args: Vec<ScVal>,
315) -> Result<InvokeContractArgs, Error> {
316    let contract_address_arg = xdr::ScAddress::Contract(ContractId(Hash(contract_id.0)));
317    let function_symbol_arg = function
318        .try_into()
319        .map_err(|()| Error::FunctionNameTooLong {
320            function_name: function.to_string(),
321            length: function.len(),
322        })?;
323
324    let final_args =
325        parsed_args
326            .clone()
327            .try_into()
328            .map_err(|_| Error::MaxNumberOfArgumentsReached {
329                current: parsed_args.len(),
330                maximum: ScVec::default().max_len(),
331            })?;
332
333    Ok(InvokeContractArgs {
334        contract_address: contract_address_arg,
335        function_name: function_symbol_arg,
336        args: final_args,
337    })
338}
339
340pub fn build_custom_cmd(name: &str, spec: &Spec) -> Result<clap::Command, Error> {
341    let func = spec
342        .find_function(name)
343        .map_err(|_| Error::FunctionNotFoundInContractSpec {
344            function_name: name.to_string(),
345            available_functions: get_available_functions(spec),
346        })?;
347
348    // Parse the function arguments
349    let inputs_map = &func
350        .inputs
351        .iter()
352        .map(|i| (sanitize(&i.name.to_utf8_string_lossy()), i.type_.clone()))
353        .collect::<HashMap<String, ScSpecTypeDef>>();
354    let name: &'static str = Box::leak(sanitize(name).into_boxed_str());
355    let mut cmd = clap::Command::new(name)
356        .no_binary_name(true)
357        .term_width(300)
358        .max_term_width(300);
359    let kebab_name = name.to_kebab_case();
360    if kebab_name != name {
361        cmd = cmd.alias(kebab_name);
362    }
363    let doc: &'static str = Box::leak(sanitize(&func.doc.to_utf8_string_lossy()).into_boxed_str());
364    let long_doc: &'static str = Box::leak(arg_file_help(doc).into_boxed_str());
365
366    cmd = cmd.about(Some(doc)).long_about(long_doc);
367    for (name, type_) in inputs_map {
368        let mut arg = clap::Arg::new(name);
369        let file_arg_name = fmt_arg_file_name(name);
370        let mut file_arg = clap::Arg::new(&file_arg_name);
371        arg = arg
372            .long(name)
373            .alias(name.to_kebab_case())
374            .num_args(1)
375            .value_parser(clap::builder::NonEmptyStringValueParser::new())
376            .long_help(
377                spec.doc(name, type_)?
378                    .map(|d| -> &'static str { Box::leak(sanitize(d).into_boxed_str()) }),
379            );
380
381        file_arg = file_arg
382            .long(&file_arg_name)
383            .alias(file_arg_name.to_kebab_case())
384            .num_args(1)
385            .hide(true)
386            .value_parser(value_parser!(PathBuf))
387            .conflicts_with(name);
388
389        if let Some(value_name) = spec.arg_value_name(type_, 0) {
390            let value_name: &'static str = Box::leak(value_name.into_boxed_str());
391            arg = arg.value_name(value_name);
392        }
393
394        // Set up special-case arg rules
395        arg = match type_ {
396            ScSpecTypeDef::Bool => arg
397                .num_args(0..1)
398                .default_missing_value("true")
399                .default_value("false")
400                .num_args(0..=1),
401            ScSpecTypeDef::Option(_val) => arg.required(false),
402            ScSpecTypeDef::I256 | ScSpecTypeDef::I128 | ScSpecTypeDef::I64 | ScSpecTypeDef::I32 => {
403                arg.allow_hyphen_values(true)
404            }
405            _ => arg,
406        };
407
408        cmd = cmd.arg(arg);
409        cmd = cmd.arg(file_arg);
410    }
411    Ok(cmd)
412}
413
414fn fmt_arg_file_name(name: &str) -> String {
415    format!("{name}-file-path")
416}
417
418fn arg_file_help(docs: &str) -> String {
419    format!(
420        r"{docs}
421Usage Notes:
422Each arg has a corresponding --<arg_name>-file-path which is a path to a file containing the corresponding JSON argument.
423Note: The only types which aren't JSON are Bytes and BytesN, which are raw bytes"
424    )
425}
426
427pub fn output_to_string(
428    spec: &Spec,
429    res: &ScVal,
430    function: &str,
431) -> Result<TxnResult<String>, Error> {
432    let mut res_str = String::new();
433    if let Some(output) = spec.find_function(function)?.outputs.first() {
434        res_str = spec
435            .xdr_to_json(res, output)
436            .map_err(|e| Error::CannotPrintResult {
437                result: res.clone(),
438                error: e,
439            })?
440            .to_string();
441    }
442    Ok(TxnResult::Res(res_str))
443}
444
445fn resolve_address(addr_or_alias: &str, config: &config::Args) -> Result<String, Error> {
446    let sc_address: UnresolvedScAddress = addr_or_alias.parse().unwrap();
447    let account = match sc_address {
448        UnresolvedScAddress::Resolved(addr) => addr.to_string(),
449        addr @ UnresolvedScAddress::Alias(_) => {
450            let addr = addr.resolve(
451                &config.locator,
452                &config.get_network()?.network_passphrase,
453                config.hd_path(),
454            )?;
455            match addr {
456                xdr::ScAddress::Account(account) => account.to_string(),
457                contract @ xdr::ScAddress::Contract(_) => contract.to_string(),
458                stellar_xdr::curr::ScAddress::MuxedAccount(account) => account.to_string(),
459                stellar_xdr::curr::ScAddress::ClaimableBalance(_)
460                | stellar_xdr::curr::ScAddress::LiquidityPool(_) => {
461                    return Err(Error::UnsupportedScAddress {
462                        address: addr.to_string(),
463                    })
464                }
465            }
466        }
467    };
468    Ok(account)
469}
470
471async fn resolve_signer(addr_or_alias: &str, config: &config::Args) -> Option<Signer> {
472    let secret = config.locator.get_secret_key(addr_or_alias).ok()?;
473    let print = Print::new(false);
474    let signer = secret.signer(config.hd_path(), print).await.ok()?;
475    Some(signer)
476}
477
478/// Validates JSON string and returns a more descriptive error if invalid
479fn validate_json_arg(arg_name: &str, value: &str) -> Result<(), Error> {
480    // Try to parse as JSON first
481    if let Err(json_err) = serde_json::from_str::<serde_json::Value>(value) {
482        return Err(Error::InvalidJsonArg {
483            arg: arg_name.to_string(),
484            json_error: json_err.to_string(),
485            received_value: value.to_string(),
486        });
487    }
488    Ok(())
489}
490
491/// Gets a human-readable type name for error messages
492fn get_type_name(type_def: &ScSpecTypeDef) -> String {
493    match type_def {
494        ScSpecTypeDef::Val => "any value".to_string(),
495        ScSpecTypeDef::U64 => "u64 (unsigned 64-bit integer)".to_string(),
496        ScSpecTypeDef::I64 => "i64 (signed 64-bit integer)".to_string(),
497        ScSpecTypeDef::U128 => "u128 (unsigned 128-bit integer)".to_string(),
498        ScSpecTypeDef::I128 => "i128 (signed 128-bit integer)".to_string(),
499        ScSpecTypeDef::U32 => "u32 (unsigned 32-bit integer)".to_string(),
500        ScSpecTypeDef::I32 => "i32 (signed 32-bit integer)".to_string(),
501        ScSpecTypeDef::U256 => "u256 (unsigned 256-bit integer)".to_string(),
502        ScSpecTypeDef::I256 => "i256 (signed 256-bit integer)".to_string(),
503        ScSpecTypeDef::Bool => "bool (true/false)".to_string(),
504        ScSpecTypeDef::Symbol => "symbol (identifier)".to_string(),
505        ScSpecTypeDef::String => "string".to_string(),
506        ScSpecTypeDef::Bytes => "bytes (raw binary data)".to_string(),
507        ScSpecTypeDef::BytesN(n) => format!("bytes{} (exactly {} bytes)", n.n, n.n),
508        ScSpecTypeDef::Address => {
509            "address (G... for account, C... for contract, or identity name)".to_string()
510        }
511        ScSpecTypeDef::MuxedAddress => "muxed address (M... or identity name)".to_string(),
512        ScSpecTypeDef::Void => "void (no value)".to_string(),
513        ScSpecTypeDef::Error => "error".to_string(),
514        ScSpecTypeDef::Timepoint => "timepoint (timestamp)".to_string(),
515        ScSpecTypeDef::Duration => "duration (time span)".to_string(),
516        ScSpecTypeDef::Option(inner) => format!("optional {}", get_type_name(&inner.value_type)),
517        ScSpecTypeDef::Vec(inner) => format!("vector of {}", get_type_name(&inner.element_type)),
518        ScSpecTypeDef::Map(map_type) => format!(
519            "map from {} to {}",
520            get_type_name(&map_type.key_type),
521            get_type_name(&map_type.value_type)
522        ),
523        ScSpecTypeDef::Tuple(tuple_type) => {
524            let types: Vec<String> = tuple_type.value_types.iter().map(get_type_name).collect();
525            format!("tuple({})", types.join(", "))
526        }
527        ScSpecTypeDef::Result(_) => "result".to_string(),
528        ScSpecTypeDef::Udt(udt) => {
529            format!(
530                "user-defined type '{}'",
531                sanitize(&udt.name.to_utf8_string_lossy())
532            )
533        }
534    }
535}
536
537/// Gets available function names for error messages
538fn get_available_functions(spec: &Spec) -> String {
539    match spec.find_functions() {
540        Ok(functions) => functions
541            .map(|f| sanitize(&f.name.to_utf8_string_lossy()))
542            .collect::<Vec<_>>()
543            .join(", "),
544        Err(_) => "unknown".to_string(),
545    }
546}
547
548/// Checks if a type is a primitive type that doesn't require JSON validation
549fn is_primitive_type(type_def: &ScSpecTypeDef) -> bool {
550    matches!(
551        type_def,
552        ScSpecTypeDef::U32
553            | ScSpecTypeDef::U64
554            | ScSpecTypeDef::U128
555            | ScSpecTypeDef::U256
556            | ScSpecTypeDef::I32
557            | ScSpecTypeDef::I64
558            | ScSpecTypeDef::I128
559            | ScSpecTypeDef::I256
560            | ScSpecTypeDef::Bool
561            | ScSpecTypeDef::Symbol
562            | ScSpecTypeDef::String
563            | ScSpecTypeDef::Bytes
564            | ScSpecTypeDef::BytesN(_)
565            | ScSpecTypeDef::Address
566            | ScSpecTypeDef::MuxedAddress
567            | ScSpecTypeDef::Timepoint
568            | ScSpecTypeDef::Duration
569            | ScSpecTypeDef::Void
570    )
571}
572
573/// Generates context-aware suggestions based on the expected type and error
574fn get_context_suggestions(expected_type: &ScSpecTypeDef, received_value: &str) -> String {
575    match expected_type {
576        ScSpecTypeDef::U64 | ScSpecTypeDef::I64 | ScSpecTypeDef::U128 | ScSpecTypeDef::I128
577        | ScSpecTypeDef::U32 | ScSpecTypeDef::I32 | ScSpecTypeDef::U256 | ScSpecTypeDef::I256 => {
578            if received_value.starts_with('"') && received_value.ends_with('"') {
579                "For numbers, ensure no quotes around the value (e.g., use 100 instead of \"100\")".to_string()
580            } else if received_value.contains('.') {
581                "Integer types don't support decimal values - use a whole number".to_string()
582            } else {
583                "Ensure the value is a valid integer within the type's range".to_string()
584            }
585        }
586        ScSpecTypeDef::Bool => {
587            "For booleans, use 'true' or 'false' (without quotes)".to_string()
588        }
589        ScSpecTypeDef::String => {
590            if !received_value.starts_with('"') || !received_value.ends_with('"') {
591                "For strings, ensure the value is properly quoted (e.g., \"hello world\")".to_string()
592            } else {
593                "Check for proper string escaping if the string contains special characters".to_string()
594            }
595        }
596        ScSpecTypeDef::Address => {
597            "For addresses, use format: G... (account), C... (contract), or identity name (e.g., alice)".to_string()
598        }
599        ScSpecTypeDef::MuxedAddress => {
600            "For muxed addresses, use format: M... or identity name".to_string()
601        }
602        ScSpecTypeDef::Vec(_) => {
603            "For arrays, use JSON array format: [\"item1\", \"item2\"] or [{\"key\": \"value\"}]".to_string()
604        }
605        ScSpecTypeDef::Map(_) => {
606            "For maps, use JSON object format: {\"key1\": \"value1\", \"key2\": \"value2\"}".to_string()
607        }
608        ScSpecTypeDef::Option(_) => {
609            "For optional values, use null for none or the expected value type".to_string()
610        }
611        _ => {
612            "Check the contract specification for the correct argument format and type".to_string()
613        }
614    }
615}
616
617/// Enhanced argument parsing with better error handling
618fn parse_argument_with_validation(
619    arg_name: &str,
620    value: &str,
621    expected_type: &ScSpecTypeDef,
622    spec: &Spec,
623    config: &config::Args,
624) -> Result<ScVal, Error> {
625    let expected_type_name = get_type_name(expected_type);
626
627    // Pre-validate JSON for non-primitive types, but skip for union (enum) UDTs since
628    // both bare strings (e.g. `Unit`) and JSON strings (e.g. `"Unit"`) are valid for
629    // unit variants — from_string in soroban-spec-tools handles both forms correctly.
630    let is_union_udt = if let ScSpecTypeDef::Udt(udt) = expected_type {
631        spec.find(&udt.name.to_utf8_string_lossy())
632            .map(|entry| matches!(entry, ScSpecEntry::UdtUnionV0(_)))
633            .unwrap_or(false)
634    } else {
635        false
636    };
637    if !is_primitive_type(expected_type) && !is_union_udt {
638        validate_json_arg(arg_name, value)?;
639    }
640
641    // Handle special address types
642    if matches!(
643        expected_type,
644        ScSpecTypeDef::Address | ScSpecTypeDef::MuxedAddress
645    ) {
646        let trimmed_value = value.trim_matches('"');
647        let addr = resolve_address(trimmed_value, config)?;
648        return spec
649            .from_string(&addr, expected_type)
650            .map_err(|error| Error::CannotParseArg {
651                arg: arg_name.to_string(),
652                error,
653                expected_type: expected_type_name.clone(),
654                received_value: value.to_string(),
655                suggestion: get_context_suggestions(expected_type, value),
656            });
657    }
658
659    // Parse the argument
660    spec.from_string(value, expected_type)
661        .map_err(|error| Error::CannotParseArg {
662            arg: arg_name.to_string(),
663            error,
664            expected_type: expected_type_name,
665            received_value: value.to_string(),
666            suggestion: get_context_suggestions(expected_type, value),
667        })
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use stellar_xdr::curr::{ScSpecTypeBytesN, ScSpecTypeDef, ScSpecTypeOption, ScSpecTypeVec};
674
675    #[test]
676    fn test_get_type_name_primitives() {
677        assert_eq!(
678            get_type_name(&ScSpecTypeDef::U32),
679            "u32 (unsigned 32-bit integer)"
680        );
681        assert_eq!(
682            get_type_name(&ScSpecTypeDef::I64),
683            "i64 (signed 64-bit integer)"
684        );
685        assert_eq!(get_type_name(&ScSpecTypeDef::Bool), "bool (true/false)");
686        assert_eq!(get_type_name(&ScSpecTypeDef::String), "string");
687        assert_eq!(
688            get_type_name(&ScSpecTypeDef::Address),
689            "address (G... for account, C... for contract, or identity name)"
690        );
691    }
692
693    #[test]
694    fn test_get_type_name_complex() {
695        let option_type = ScSpecTypeDef::Option(Box::new(ScSpecTypeOption {
696            value_type: Box::new(ScSpecTypeDef::U32),
697        }));
698        assert_eq!(
699            get_type_name(&option_type),
700            "optional u32 (unsigned 32-bit integer)"
701        );
702
703        let vec_type = ScSpecTypeDef::Vec(Box::new(ScSpecTypeVec {
704            element_type: Box::new(ScSpecTypeDef::String),
705        }));
706        assert_eq!(get_type_name(&vec_type), "vector of string");
707    }
708
709    #[test]
710    fn test_is_primitive_type_all_primitives() {
711        assert!(is_primitive_type(&ScSpecTypeDef::U32));
712        assert!(is_primitive_type(&ScSpecTypeDef::I32));
713        assert!(is_primitive_type(&ScSpecTypeDef::U64));
714        assert!(is_primitive_type(&ScSpecTypeDef::I64));
715        assert!(is_primitive_type(&ScSpecTypeDef::U128));
716        assert!(is_primitive_type(&ScSpecTypeDef::I128));
717        assert!(is_primitive_type(&ScSpecTypeDef::U256));
718        assert!(is_primitive_type(&ScSpecTypeDef::I256));
719
720        assert!(is_primitive_type(&ScSpecTypeDef::Bool));
721        assert!(is_primitive_type(&ScSpecTypeDef::Symbol));
722        assert!(is_primitive_type(&ScSpecTypeDef::String));
723        assert!(is_primitive_type(&ScSpecTypeDef::Void));
724        assert!(is_primitive_type(&ScSpecTypeDef::Bytes));
725        assert!(is_primitive_type(&ScSpecTypeDef::BytesN(
726            ScSpecTypeBytesN { n: 32 }
727        )));
728        assert!(is_primitive_type(&ScSpecTypeDef::BytesN(
729            ScSpecTypeBytesN { n: 64 }
730        )));
731
732        assert!(is_primitive_type(&ScSpecTypeDef::Address));
733        assert!(is_primitive_type(&ScSpecTypeDef::MuxedAddress));
734        assert!(is_primitive_type(&ScSpecTypeDef::Timepoint));
735        assert!(is_primitive_type(&ScSpecTypeDef::Duration));
736
737        assert!(!is_primitive_type(&ScSpecTypeDef::Vec(Box::new(
738            ScSpecTypeVec {
739                element_type: Box::new(ScSpecTypeDef::U32),
740            }
741        ))));
742    }
743
744    #[test]
745    fn test_validate_json_arg_valid() {
746        // Valid JSON should not return an error
747        assert!(validate_json_arg("test_arg", r#"{"key": "value"}"#).is_ok());
748        assert!(validate_json_arg("test_arg", "123").is_ok());
749        assert!(validate_json_arg("test_arg", r#""string""#).is_ok());
750        assert!(validate_json_arg("test_arg", "true").is_ok());
751        assert!(validate_json_arg("test_arg", "null").is_ok());
752    }
753
754    #[test]
755    fn test_validate_json_arg_invalid() {
756        // Invalid JSON should return an error
757        let result = validate_json_arg("test_arg", r#"{"key": value}"#); // Missing quotes around value
758        assert!(result.is_err());
759
760        if let Err(Error::InvalidJsonArg {
761            arg,
762            json_error,
763            received_value,
764        }) = result
765        {
766            assert_eq!(arg, "test_arg");
767            assert_eq!(received_value, r#"{"key": value}"#);
768            assert!(json_error.contains("expected"));
769        } else {
770            panic!("Expected InvalidJsonArg error");
771        }
772    }
773
774    #[test]
775    fn test_validate_json_arg_malformed() {
776        // Test various malformed JSON cases
777        let test_cases = vec![
778            r#"{"key": }"#,         // Missing value
779            r#"{key: "value"}"#,    // Missing quotes around key
780            r#"{"key": "value",}"#, // Trailing comma
781            r#"{"key" "value"}"#,   // Missing colon
782        ];
783
784        for case in test_cases {
785            let result = validate_json_arg("test_arg", case);
786            assert!(result.is_err(), "Expected error for case: {case}");
787        }
788    }
789
790    #[test]
791    fn test_context_aware_error_messages() {
792        use stellar_xdr::curr::ScSpecTypeDef;
793
794        // Test context-aware suggestions for different types
795
796        // Test u64 with quoted value
797        let suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "\"100\"");
798        assert!(suggestion.contains("no quotes around the value"));
799        assert!(suggestion.contains("use 100 instead of \"100\""));
800
801        // Test u64 with decimal value
802        let suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "100.5");
803        assert!(suggestion.contains("don't support decimal values"));
804
805        // Test string without quotes
806        let suggestion = get_context_suggestions(&ScSpecTypeDef::String, "hello");
807        assert!(suggestion.contains("properly quoted"));
808
809        // Test address type
810        let suggestion = get_context_suggestions(&ScSpecTypeDef::Address, "invalid_addr");
811        assert!(suggestion.contains("G... (account), C... (contract)"));
812
813        // Test boolean type
814        let suggestion = get_context_suggestions(&ScSpecTypeDef::Bool, "yes");
815        assert!(suggestion.contains("'true' or 'false'"));
816
817        println!("=== Context-Aware Error Message Examples ===");
818        println!("U64 with quotes: {suggestion}");
819
820        let decimal_suggestion = get_context_suggestions(&ScSpecTypeDef::U64, "100.5");
821        println!("U64 with decimal: {decimal_suggestion}");
822
823        let string_suggestion = get_context_suggestions(&ScSpecTypeDef::String, "hello");
824        println!("String without quotes: {string_suggestion}");
825
826        let address_suggestion = get_context_suggestions(&ScSpecTypeDef::Address, "invalid");
827        println!("Invalid address: {address_suggestion}");
828    }
829
830    #[test]
831    fn test_union_udt_bare_string_accepted() {
832        use stellar_xdr::curr::{
833            ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecUdtUnionCaseV0,
834            ScSpecUdtUnionCaseVoidV0, ScSpecUdtUnionV0, StringM,
835        };
836
837        // Build a minimal Spec with a union type: enum MyEnum { Unit }
838        let union_name: StringM<60> = "MyEnum".try_into().unwrap();
839        let case_name: StringM<60> = "Unit".try_into().unwrap();
840        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
841            doc: StringM::default(),
842            lib: StringM::default(),
843            name: union_name.clone(),
844            cases: vec![ScSpecUdtUnionCaseV0::VoidV0(ScSpecUdtUnionCaseVoidV0 {
845                doc: StringM::default(),
846                name: case_name,
847            })]
848            .try_into()
849            .unwrap(),
850        })]));
851
852        let expected_type = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
853        let config = crate::config::Args::default();
854
855        // Bare string (no JSON quoting) should be accepted
856        let result =
857            parse_argument_with_validation("value", "Unit", &expected_type, &spec, &config);
858        assert!(result.is_ok(), "bare 'Unit' should be accepted: {result:?}");
859
860        // JSON-quoted string should also be accepted
861        let result =
862            parse_argument_with_validation("value", "\"Unit\"", &expected_type, &spec, &config);
863        assert!(
864            result.is_ok(),
865            "JSON-quoted '\"Unit\"' should be accepted: {result:?}"
866        );
867
868        // Both forms should produce the same ScVal
869        let bare = parse_argument_with_validation("value", "Unit", &expected_type, &spec, &config)
870            .unwrap();
871        let quoted =
872            parse_argument_with_validation("value", "\"Unit\"", &expected_type, &spec, &config)
873                .unwrap();
874        assert_eq!(
875            bare, quoted,
876            "bare and quoted forms should produce identical ScVal"
877        );
878    }
879
880    #[test]
881    fn test_union_udt_tuple_variant_still_requires_json() {
882        use stellar_xdr::curr::{
883            ScSpecEntry, ScSpecTypeDef, ScSpecTypeUdt, ScSpecUdtUnionCaseTupleV0,
884            ScSpecUdtUnionCaseV0, ScSpecUdtUnionCaseVoidV0, ScSpecUdtUnionV0, StringM,
885        };
886
887        let union_name: StringM<60> = "MyEnum".try_into().unwrap();
888        let spec = Spec(Some(vec![ScSpecEntry::UdtUnionV0(ScSpecUdtUnionV0 {
889            doc: StringM::default(),
890            lib: StringM::default(),
891            name: union_name.clone(),
892            cases: vec![
893                ScSpecUdtUnionCaseV0::VoidV0(ScSpecUdtUnionCaseVoidV0 {
894                    doc: StringM::default(),
895                    name: "Unit".try_into().unwrap(),
896                }),
897                ScSpecUdtUnionCaseV0::TupleV0(ScSpecUdtUnionCaseTupleV0 {
898                    doc: StringM::default(),
899                    name: "WithValue".try_into().unwrap(),
900                    type_: vec![ScSpecTypeDef::U32].try_into().unwrap(),
901                }),
902            ]
903            .try_into()
904            .unwrap(),
905        })]));
906
907        let expected_type = ScSpecTypeDef::Udt(ScSpecTypeUdt { name: union_name });
908        let config = crate::config::Args::default();
909
910        // Tuple variant with a value must still use JSON object syntax
911        let result = parse_argument_with_validation(
912            "value",
913            r#"{"WithValue":42}"#,
914            &expected_type,
915            &spec,
916            &config,
917        );
918        assert!(
919            result.is_ok(),
920            "JSON object for tuple variant should be accepted: {result:?}"
921        );
922    }
923
924    #[test]
925    fn test_error_message_format() {
926        use stellar_xdr::curr::ScSpecTypeDef;
927
928        // Test that our CannotParseArg error formats correctly
929        let error = Error::CannotParseArg {
930            arg: "amount".to_string(),
931            error: soroban_spec_tools::Error::InvalidValue(Some(ScSpecTypeDef::U64)),
932            expected_type: "u64 (unsigned 64-bit integer)".to_string(),
933            received_value: "\"100\"".to_string(),
934            suggestion:
935                "For numbers, ensure no quotes around the value (e.g., use 100 instead of \"100\")"
936                    .to_string(),
937        };
938
939        let error_message = format!("{error}");
940        println!("\n=== Complete Error Message Example ===");
941        println!("{error_message}");
942
943        // Verify the error message contains all expected parts
944        assert!(error_message.contains("Failed to parse argument 'amount'"));
945        assert!(error_message.contains("Expected type u64 (unsigned 64-bit integer)"));
946        assert!(error_message.contains("received: '\"100\"'"));
947        assert!(error_message.contains("Suggestion: For numbers, ensure no quotes"));
948    }
949
950    /// Mirrors `stellar contract invoke`: Spec::from_wasm -> build_clap_command -> render_long_help.
951    #[test]
952    fn invoke_help_strips_control_characters() {
953        let path = concat!(
954            env!("CARGO_MANIFEST_DIR"),
955            "/../crates/soroban-spec-tools/tests/fixtures/control_characters.wasm"
956        );
957        let bytes = std::fs::read(path).expect("fixture wasm should be readable");
958        let spec = Spec::from_wasm(&bytes).expect("wasm should parse without error");
959        let mut cmd = build_clap_command(&spec, true).expect("command should build without error");
960        let help = cmd.render_long_help().to_string();
961
962        let bad_chars: Vec<char> = help
963            .chars()
964            .filter(|c| c.is_control() && *c != '\n' && *c != '\t')
965            .collect();
966        assert!(
967            bad_chars.is_empty(),
968            "invoke help contains unexpected control characters {bad_chars:?}:\n{help:?}"
969        );
970    }
971}