diff --git a/cmd/crates/soroban-test/tests/it/custom_types.rs b/cmd/crates/soroban-test/tests/it/custom_types.rs index a76c4ab6d..9fb1008b0 100644 --- a/cmd/crates/soroban-test/tests/it/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/custom_types.rs @@ -59,6 +59,19 @@ fn multi_arg_success() { .stdout("42\n"); } +#[test] +fn bytes_as_file() { + let env = &TestEnv::default(); + let path = env.temp_dir.join("bytes.txt"); + std::fs::write(&path, 0x0073_7465_6c6c_6172u128.to_be_bytes()).unwrap(); + invoke(env, "bytes") + .arg("--bytes-file-path") + .arg(path) + .assert() + .success() + .stdout("\"0000000000000000007374656c6c6172\"\n"); +} + #[test] fn map() { invoke_with_roundtrip("map", json!({"0": true, "1": false})); diff --git a/cmd/crates/soroban-test/tests/it/invoke_sandbox.rs b/cmd/crates/soroban-test/tests/it/invoke_sandbox.rs index 9ac0ae0e4..cc372df9b 100644 --- a/cmd/crates/soroban-test/tests/it/invoke_sandbox.rs +++ b/cmd/crates/soroban-test/tests/it/invoke_sandbox.rs @@ -89,6 +89,47 @@ fn invoke_hello_world() { .success(); } +#[test] +fn invoke_hello_world_from_file() { + let sandbox = TestEnv::default(); + let tmp_file = sandbox.temp_dir.join("world.txt"); + std::fs::write(&tmp_file, "world").unwrap(); + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--id=1") + .arg("--wasm") + .arg(HELLO_WORLD.path()) + .arg("--") + .arg("hello") + .arg("--world-file-path") + .arg(&tmp_file) + .assert() + .stdout("[\"Hello\",\"world\"]\n") + .success(); +} + +#[test] +fn invoke_hello_world_from_file_fail() { + let sandbox = TestEnv::default(); + let tmp_file = sandbox.temp_dir.join("world.txt"); + std::fs::write(&tmp_file, "world").unwrap(); + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--id=1") + .arg("--wasm") + .arg(HELLO_WORLD.path()) + .arg("--") + .arg("hello") + .arg("--world-file-path") + .arg(&tmp_file) + .arg("--world=hello") + .assert() + .stderr(predicates::str::contains("error: the argument '--world-file-path ' cannot be used with '--world '")) + .failure(); +} + #[test] fn invoke_hello_world_with_lib() { TestEnv::with_default(|e| { diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index 3bfc8653c..b401d9bda 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{fmt::Debug, fs, io, rc::Rc}; -use clap::{arg, command, Parser}; +use clap::{arg, command, value_parser, Parser}; use heck::ToKebabCase; use soroban_env_host::{ budget::Budget, @@ -58,7 +58,7 @@ pub struct Cmd { help_heading = HEADING_SANDBOX)] pub unlimited_budget: bool, - // Function name as subcommand, then arguments for that function as `--arg-name value` + /// Function name as subcommand, then arguments for that function as `--arg-name value` #[arg(last = true, id = "CONTRACT_FN_AND_ARGS")] pub slop: Vec, @@ -153,6 +153,8 @@ pub enum Error { StrKey(#[from] stellar_strkey::DecodeError), #[error(transparent)] ContractSpec(#[from] contract_spec::Error), + #[error("")] + MissingFileArg(PathBuf), } impl From for Error { @@ -203,6 +205,26 @@ impl Cmd { .map_err(|error| Error::CannotParseArg { arg: name, error }) } else if matches!(i.type_, ScSpecTypeDef::Option(_)) { Ok(ScVal::Void) + } else if let Some(arg_path) = + matches_.get_one::(&fmt_arg_file_name(&name)) + { + if matches!(i.type_, ScSpecTypeDef::Bytes | ScSpecTypeDef::BytesN(_)) { + Ok(ScVal::try_from( + &std::fs::read(arg_path) + .map_err(|_| Error::MissingFileArg(arg_path.clone()))?, + ) + .unwrap()) + } else { + let file_contents = std::fs::read_to_string(arg_path) + .map_err(|_| Error::MissingFileArg(arg_path.clone()))?; + tracing::debug!( + "file {arg_path:?}, has contents:\n{file_contents}\nAnd type {:#?}\n{}", + i.type_, + file_contents.len() + ); + spec.from_string(&file_contents, &i.type_) + .map_err(|error| Error::CannotParseArg { arg: name, error }) + } } else { Err(Error::MissingArgument(name)) } @@ -521,10 +543,12 @@ fn build_custom_cmd(name: &str, spec: &Spec) -> Result { cmd = cmd.alias(kebab_name); } let func = spec.find_function(name).unwrap(); - let doc: &'static str = Box::leak(func.doc.to_string_lossy().into_boxed_str()); + let doc: &'static str = Box::leak(arg_file_help(&func.doc.to_string_lossy()).into_boxed_str()); cmd = cmd.about(Some(doc)); for (name, type_) in inputs_map.iter() { let mut arg = clap::Arg::new(name); + let file_arg_name = fmt_arg_file_name(name); + let mut file_arg = clap::Arg::new(&file_arg_name); arg = arg .long(name) .alias(name.to_kebab_case()) @@ -532,6 +556,14 @@ fn build_custom_cmd(name: &str, spec: &Spec) -> Result { .value_parser(clap::builder::NonEmptyStringValueParser::new()) .long_help(spec.doc(name, type_).unwrap()); + file_arg = file_arg + .long(&file_arg_name) + .alias(file_arg_name.to_kebab_case()) + .num_args(1) + .hide(true) + .value_parser(value_parser!(PathBuf)) + .conflicts_with(name); + if let Some(value_name) = spec.arg_value_name(type_, 0) { let value_name: &'static str = Box::leak(value_name.into_boxed_str()); arg = arg.value_name(value_name); @@ -553,6 +585,20 @@ fn build_custom_cmd(name: &str, spec: &Spec) -> Result { }; cmd = cmd.arg(arg); + cmd = cmd.arg(file_arg); } Ok(cmd) } + +fn fmt_arg_file_name(name: &str) -> String { + format!("{name}-file-path") +} + +fn arg_file_help(docs: &str) -> String { + format!( + r#"{docs} +Usage Notes: +Each arg has a corresponding ---file-path which is a path to a file containing the corresponding JSON argument. +Note: The only types which aren't JSON are Bytes and Bytes which are raw bytes"# + ) +} diff --git a/docs/soroban-cli-full-docs.md b/docs/soroban-cli-full-docs.md index 7a5e29879..0e1170470 100644 --- a/docs/soroban-cli-full-docs.md +++ b/docs/soroban-cli-full-docs.md @@ -331,7 +331,7 @@ soroban contract invoke ... -- --help ###### **Arguments:** -* `` +* `` — Function name as subcommand, then arguments for that function as `--arg-name value` ###### **Options:**