diff --git a/spel-cli/src/cli.rs b/spel-cli/src/cli.rs index 8eed8a7..9a11e51 100644 --- a/spel-cli/src/cli.rs +++ b/spel-cli/src/cli.rs @@ -61,6 +61,7 @@ pub fn print_help(idl: &SpelIdl, binary_name: &str) { println!(" [u8; N] Hex string (2*N hex chars) or UTF-8 string (≤N chars, right-padded)"); println!(" [u32; 8] / program_id Comma-separated u32s: \"0,0,0,0,0,0,0,0\""); println!(" Vec<[u8; 32]> Comma-separated hex strings: \"aabb...00,ccdd...00\""); + println!(" Vec Repeat the flag, one element per occurrence: --foo a --foo b --foo c"); println!(); println!("CONFIG:"); println!(" Create a spel.toml in your project root to avoid passing --idl and --program:"); @@ -126,18 +127,40 @@ pub fn print_instruction_help(ix: &IdlInstruction) { } } -/// Parse CLI args for an instruction into a key-value map. -pub fn parse_instruction_args(args: &[String], ix: &IdlInstruction) -> HashMap { - let mut map = HashMap::new(); +/// Parse CLI args for an instruction into a key → list-of-values map. +/// +/// Each `--key value` occurrence is appended to the entry for `key`, so a +/// flag may be repeated. Scalar args take the last value; `Vec` +/// args consume every value supplied. A bare `--flag` with no value is +/// only legal for boolean args (per the IDL) or the universal `--help`/ +/// `-h`; for anything else we exit with a `missing value` error so a +/// missing CLI value can't be silently stored as the literal "true". +pub fn parse_instruction_args( + args: &[String], + ix: &IdlInstruction, +) -> HashMap> { + let mut map: HashMap> = HashMap::new(); let mut i = 0; while i < args.len() { if args[i].starts_with("--") { let key = args[i][2..].to_string(); if i + 1 < args.len() && !args[i + 1].starts_with("--") { - map.insert(key, args[i + 1].clone()); + map.entry(key).or_default().push(args[i + 1].clone()); i += 2; } else { - map.insert(key, "true".to_string()); + // Bare flag (no value follows). Only valid for bool args + // or --help/-h; everything else is a user error. + let is_bool = ix.args.iter().any(|a| { + snake_to_kebab(&a.name) == key + && matches!(&a.type_, IdlType::Primitive(p) if p == "bool") + }); + let is_help = key == "help" || key == "h"; + if is_bool || is_help { + map.entry(key).or_default().push("true".to_string()); + } else { + eprintln!("❌ --{}: missing value", key); + std::process::exit(1); + } i += 1; } } else { diff --git a/spel-cli/src/parse.rs b/spel-cli/src/parse.rs index 705c0f8..d0a85d5 100644 --- a/spel-cli/src/parse.rs +++ b/spel-cli/src/parse.rs @@ -15,6 +15,7 @@ pub enum ParsedValue { ByteArray(Vec), // [u8; N] U32Array(Vec), // [u32; N] / ProgramId ByteArrayVec(Vec>), // Vec<[u8; 32]> + StringVec(Vec), // Vec None, // Option::None Some(Box), // Option::Some Raw(String), // fallback @@ -49,6 +50,10 @@ impl std::fmt::Display for ParsedValue { .collect(); write!(f, "[{}]", strs.join(", ")) }, + ParsedValue::StringVec(strs) => { + let quoted: Vec = strs.iter().map(|s| format!("\"{}\"", s)).collect(); + write!(f, "[{}]", quoted.join(", ")) + }, ParsedValue::None => write!(f, "None"), ParsedValue::Some(inner) => write!(f, "Some({})", inner), ParsedValue::Raw(s) => write!(f, "{}", s), @@ -247,6 +252,14 @@ fn parse_vec(raw: &str, elem_type: &IdlType) -> Result { } } +/// Build a `ParsedValue::StringVec` from one or more repeated `--flag ` +/// occurrences on the CLI. The caller is expected to have collected every +/// occurrence of the flag — empty input yields an empty vec, matching the +/// IDL contract `Vec`. +pub fn parse_string_vec(values: &[String]) -> ParsedValue { + ParsedValue::StringVec(values.to_vec()) +} + #[cfg(test)] mod tests { use super::*; @@ -277,6 +290,43 @@ mod tests { } } + #[test] + fn parse_string_vec_multiple_values() { + let parsed = parse_string_vec(&["foo".to_string(), "bar".to_string(), "baz".to_string()]); + match parsed { + ParsedValue::StringVec(v) => assert_eq!(v, vec!["foo", "bar", "baz"]), + other => panic!("expected StringVec, got {:?}", other), + } + } + + #[test] + fn parse_string_vec_empty_input_yields_empty_vec() { + match parse_string_vec(&[]) { + ParsedValue::StringVec(v) => assert!(v.is_empty()), + other => panic!("expected empty StringVec, got {:?}", other), + } + } + + #[test] + fn parse_string_vec_single_element_yields_singleton() { + match parse_string_vec(&["bafybeionly".to_string()]) { + ParsedValue::StringVec(v) => assert_eq!(v, vec!["bafybeionly"]), + other => panic!("expected StringVec, got {:?}", other), + } + } + + #[test] + fn parse_string_vec_preserves_commas_in_elements() { + // Repetition contract: an element containing a comma is one element, + // never split. This is the user-facing difference from the previous + // CSV approach. + let parsed = parse_string_vec(&["foo,bar".to_string(), "baz".to_string()]); + match parsed { + ParsedValue::StringVec(v) => assert_eq!(v, vec!["foo,bar", "baz"]), + other => panic!("expected StringVec, got {:?}", other), + } + } + #[test] fn primitive_bytes32_string_does_not_parse_as_byte_array() { // This is the bug: the macro emits Primitive("[u8; 32]") which diff --git a/spel-cli/src/serialize.rs b/spel-cli/src/serialize.rs index 1b40c79..67a6c06 100644 --- a/spel-cli/src/serialize.rs +++ b/spel-cli/src/serialize.rs @@ -88,6 +88,11 @@ fn to_dynamic_value(ty: &IdlType, val: &ParsedValue) -> Result { + Ok(DynamicValue::Seq( + strs.iter().cloned().map(DynamicValue::Str).collect(), + )) + }, (IdlType::Vec { vec }, ParsedValue::Raw(s)) if matches!(vec.as_ref(), IdlType::Primitive(p) if p == "u32") => { // Fallback: parse CSV of u32 values (e.g. "0,200,0,0,0") @@ -173,7 +178,7 @@ pub fn serialize_to_risc0( #[cfg(test)] mod tests { use super::*; - use crate::parse::parse_value; + use crate::parse::{parse_string_vec, parse_value}; use risc0_zkvm::serde::Deserializer; use serde::Deserialize; use spel_framework_core::idl::IdlType; @@ -402,6 +407,57 @@ mod tests { assert_eq!(words, vec![3, 1, 2, 3]); // length=3, then values } + #[test] + fn to_dynamic_value_vec_string_emits_seq_of_str() { + let ty = IdlType::Vec { + vec: Box::new(IdlType::Primitive("string".to_string())), + }; + let parsed = parse_string_vec(&["foo".to_string(), "bar".to_string(), "baz".to_string()]); + let dv = to_dynamic_value(&ty, &parsed).unwrap(); + let words = risc0_zkvm::serde::to_vec(&dv).unwrap(); + // Seq of 3 strings: length=3, then each string is its own length + bytes + // (one u32 word per byte, then padded to next word boundary). + // We assert the length prefix here and rely on serde_roundtrip_vec_string + // for the full bit-level contract check. + assert_eq!(words[0], 3, "Seq length prefix"); + } + + /// The critical contract test for Vec: bytes the CLI emits must + /// deserialize as Vec via the same risc0 Deserializer the guest uses. + #[test] + fn serde_roundtrip_vec_string() { + #[derive(Deserialize, Debug, PartialEq)] + enum TestInstruction { + AnchorBatch { cids: Vec }, + } + + let ty = IdlType::Vec { + vec: Box::new(IdlType::Primitive("string".to_string())), + }; + let parsed = parse_string_vec(&[ + "bafy1".to_string(), + "bafy2".to_string(), + "bafy3".to_string(), + ]); + + let words = serialize_to_risc0(0, &[(&ty, &parsed)]).unwrap(); + + let instruction: TestInstruction = + TestInstruction::deserialize(&mut Deserializer::new(words.as_ref())) + .expect("guest-side Vec deserialization must succeed"); + + assert_eq!( + instruction, + TestInstruction::AnchorBatch { + cids: vec![ + "bafy1".to_string(), + "bafy2".to_string(), + "bafy3".to_string() + ], + } + ); + } + #[test] fn to_dynamic_value_type_mismatch_returns_err() { let ty = IdlType::Primitive("u8".to_string()); diff --git a/spel-cli/src/tx.rs b/spel-cli/src/tx.rs index 5e5e59c..c6cf231 100644 --- a/spel-cli/src/tx.rs +++ b/spel-cli/src/tx.rs @@ -2,7 +2,7 @@ use crate::cli::{snake_to_kebab, to_pascal_case}; use crate::hex::{decode_bytes_32, hex_encode, parse_account_id}; -use crate::parse::{parse_value, ParsedValue}; +use crate::parse::{parse_string_vec, parse_value, ParsedValue}; use crate::pda::compute_pda_from_seeds; use crate::serialize::serialize_to_risc0; use common::transaction::NSSATransaction; @@ -14,7 +14,7 @@ use nssa_core::account::Nonce; use nssa_core::program::ProgramId; use sequencer_service_rpc::RpcClient as _; use serde_json::{json, Value}; -use spel_framework_core::idl::{IdlInstruction, IdlSeed, SpelIdl}; +use spel_framework_core::idl::{IdlInstruction, IdlSeed, IdlType, SpelIdl}; use std::collections::HashMap; use std::fs; use std::process; @@ -48,10 +48,26 @@ const UNRESOLVED: &str = "(unresolved)"; /// - `None` — submit to the sequencer /// - `Some(DryRunFormat::Text)` — resolve & print a human-readable summary /// - `Some(DryRunFormat::Json)` — resolve & emit JSON to stdout +/// Pull the most-recently-supplied value for `key`. Scalar flags consume +/// the last `--key value` they see; `Vec` args bypass this helper +/// and consume the whole vec. +fn last_value<'a>(map: &'a HashMap>, key: &str) -> Option<&'a str> { + map.get(key).and_then(|v| v.last().map(|s| s.as_str())) +} + +/// True if this IDL type is `Vec` — the one shape that opts in to +/// flag repetition on the CLI. +fn is_vec_string(ty: &IdlType) -> bool { + matches!( + ty, + IdlType::Vec { vec } if matches!(vec.as_ref(), IdlType::Primitive(p) if p == "string" || p == "String"), + ) +} + pub async fn execute_instruction( idl: &SpelIdl, ix: &IdlInstruction, - args: &HashMap, + args: &HashMap>, program_path: Option<&str>, program_id_hex: Option<&str>, dry_run: Option, @@ -75,7 +91,7 @@ pub async fn execute_instruction( let id_str: Vec = id.iter().map(|w| w.to_string()).collect(); let val = id_str.join(","); say!(" ℹ️ Auto-filled --{} from {}", key, bin_path); - args.insert(key.clone(), val); + args.insert(key.clone(), vec![val]); } } } @@ -103,13 +119,20 @@ pub async fn execute_instruction( process::exit(1); } - // Parse instruction args + // Parse instruction args. + // Vec is the one shape that consumes every repeated --flag + // value; every other type takes the last occurrence (scalar semantics). let mut parsed_args: Vec<(&str, &spel_framework_core::idl::IdlType, ParsedValue)> = Vec::new(); let mut has_errors = false; for arg in &ix.args { let key = snake_to_kebab(&arg.name); - let raw = args.get(&key).unwrap(); - match parse_value(raw, &arg.type_) { + let values = args.get(&key).unwrap(); + let result = if is_vec_string(&arg.type_) { + Ok(parse_string_vec(values)) + } else { + parse_value(values.last().map(|s| s.as_str()).unwrap_or(""), &arg.type_) + }; + match result { Ok(val) => parsed_args.push((&arg.name, &arg.type_, val)), Err(e) => { eprintln!("❌ --{}: {}", key, e); @@ -131,7 +154,7 @@ pub async fn execute_instruction( // variadic: optional, comma-separated list of account IDs (0 entries is valid). // Failed entries are skipped (not pushed as placeholders) so downstream // consumers never see a zero-length Vec where a 32-byte AccountId is expected. - let entries: Vec<(Vec, bool)> = if let Some(raw) = args.get(&key) { + let entries: Vec<(Vec, bool)> = if let Some(raw) = last_value(&args, &key) { raw.split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) @@ -149,7 +172,7 @@ pub async fn execute_instruction( }; rest_accounts.push((&acc.name, entries)); } else { - let raw = args.get(&key).unwrap(); + let raw = last_value(&args, &key).unwrap(); match parse_account_id(raw) { Ok((bytes, is_priv)) => parsed_accounts.push((&acc.name, bytes.to_vec(), is_priv)), Err(e) => { @@ -239,7 +262,7 @@ pub async fn execute_instruction( if let IdlSeed::Account { path } = seed { if !account_map.contains_key(path) { let key = snake_to_kebab(path); - if let Some(raw) = args.get(&key) { + if let Some(raw) = last_value(&args, &key) { match decode_bytes_32(raw) { Ok(bytes) => { say!(" ℹ️ Using --{} for PDA seed '{}'", key, path);