cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 173153061595c8349a1a7091f47f93201b441f5b
parent 0a9453fe47d47f6e8b11f8ad39570d72c6cf813d
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 03:40:29 +0000

cli: add mvp flow acceptance

- parse target inputs for buyer and seller mvp operations
- cover buyer basket quote and order submit flow through the public binary
- cover seller listing create validate publish and order list flow through the public binary
- update README and command-surface docs for the target cli contract

Diffstat:
Msrc/operation_adapter.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/target_cli.rs | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtests/target_cli.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 446 insertions(+), 50 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -357,15 +357,27 @@ macro_rules! mvp_operation_contracts { impl MvpOperationRequest { pub fn from_target_args(args: &TargetCliArgs) -> Result<Self, OperationAdapterError> { - Self::from_operation_id(args.command.operation_id(), OperationContext::from_target_args(args)) + Self::from_operation_id_with_input( + args.command.operation_id(), + OperationContext::from_target_args(args), + target_operation_input(&args.command), + ) } pub fn from_operation_id( operation_id: &'static str, context: OperationContext, ) -> Result<Self, OperationAdapterError> { + Self::from_operation_id_with_input(operation_id, context, OperationData::new()) + } + + fn from_operation_id_with_input( + operation_id: &'static str, + context: OperationContext, + input: OperationData, + ) -> Result<Self, OperationAdapterError> { match operation_id { - $( $operation_id => Ok(Self::$variant(OperationRequest::new(context, $request::default())?)), )+ + $( $operation_id => Ok(Self::$variant(OperationRequest::new(context, $request::from_data(input))?)), )+ _ => Err(OperationAdapterError::UnknownOperation(operation_id.to_owned())), } } @@ -504,6 +516,133 @@ fn value_to_data(value: Value) -> OperationData { } } +fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData { + use crate::target_cli::{ + BasketCommand, BasketItemCommand, BasketQuoteCommand, ListingCommand, MarketCommand, + MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand, + }; + + let mut input = OperationData::new(); + match command { + TargetCommand::Listing(args) => match &args.command { + ListingCommand::Create(args) => { + insert_path(&mut input, "output", &args.output); + insert_string(&mut input, "key", &args.key); + insert_string(&mut input, "title", &args.title); + insert_string(&mut input, "category", &args.category); + insert_string(&mut input, "summary", &args.summary); + insert_string(&mut input, "bin_id", &args.bin_id); + insert_string(&mut input, "quantity_amount", &args.quantity_amount); + insert_string(&mut input, "quantity_unit", &args.quantity_unit); + insert_string(&mut input, "price_amount", &args.price_amount); + insert_string(&mut input, "price_currency", &args.price_currency); + insert_string(&mut input, "price_per_amount", &args.price_per_amount); + insert_string(&mut input, "price_per_unit", &args.price_per_unit); + insert_string(&mut input, "available", &args.available); + insert_string(&mut input, "label", &args.label); + } + ListingCommand::Get(args) => insert_string(&mut input, "key", &args.key), + ListingCommand::Update(args) + | ListingCommand::Validate(args) + | ListingCommand::Publish(args) + | ListingCommand::Archive(args) => insert_path(&mut input, "file", &args.file), + ListingCommand::List => {} + }, + TargetCommand::Market(args) => match &args.command { + MarketCommand::Product(product) => match &product.command { + MarketProductCommand::Search(args) => { + insert_string_array(&mut input, "query", args.query.as_slice()) + } + }, + MarketCommand::Listing(listing) => match &listing.command { + MarketListingCommand::Get(args) => insert_string(&mut input, "key", &args.key), + }, + MarketCommand::Refresh => {} + }, + TargetCommand::Basket(args) => match &args.command { + BasketCommand::Create(args) => { + insert_string(&mut input, "basket_id", &args.basket_id); + insert_string(&mut input, "listing", &args.listing); + insert_string(&mut input, "listing_addr", &args.listing_addr); + insert_string(&mut input, "bin_id", &args.bin_id); + insert_string(&mut input, "quantity", &args.quantity); + } + BasketCommand::Get(args) | BasketCommand::Validate(args) => { + insert_string(&mut input, "basket_id", &args.basket_id) + } + BasketCommand::Item(item) => match &item.command { + BasketItemCommand::Add(args) | BasketItemCommand::Update(args) => { + insert_string(&mut input, "basket_id", &args.basket_id); + insert_string(&mut input, "item_id", &args.item_id); + insert_string(&mut input, "listing", &args.listing); + insert_string(&mut input, "listing_addr", &args.listing_addr); + insert_string(&mut input, "bin_id", &args.bin_id); + insert_string(&mut input, "quantity", &args.quantity); + } + BasketItemCommand::Remove(args) => { + insert_string(&mut input, "basket_id", &args.basket_id); + insert_string(&mut input, "item_id", &args.item_id); + } + }, + BasketCommand::Quote(quote) => match &quote.command { + BasketQuoteCommand::Create(args) => { + insert_string(&mut input, "basket_id", &args.basket_id) + } + }, + BasketCommand::List => {} + }, + TargetCommand::Order(args) => match &args.command { + OrderCommand::Submit(args) => { + insert_string(&mut input, "order_id", &args.order_id); + if args.watch { + input.insert("watch".to_owned(), Value::Bool(true)); + } + } + OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id), + OrderCommand::Event(event) => match &event.command { + OrderEventCommand::List(args) | OrderEventCommand::Watch(args) => { + insert_string(&mut input, "order_id", &args.order_id) + } + }, + OrderCommand::List => {} + }, + _ => {} + } + input +} + +fn insert_string(input: &mut OperationData, key: &str, value: &Option<String>) { + if let Some(value) = value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + input.insert(key.to_owned(), Value::String(value.to_owned())); + } +} + +fn insert_string_array(input: &mut OperationData, key: &str, values: &[String]) { + let values = values + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| Value::String(value.to_owned())) + .collect::<Vec<_>>(); + if !values.is_empty() { + input.insert(key.to_owned(), Value::Array(values)); + } +} + +fn insert_path(input: &mut OperationData, key: &str, value: &Option<std::path::PathBuf>) { + if let Some(value) = value { + input.insert( + key.to_owned(), + Value::String(value.to_string_lossy().into_owned()), + ); + } +} + mvp_operation_contracts! { WorkspaceInit => (WorkspaceInitRequest, WorkspaceInitResult, "workspace.init"), WorkspaceGet => (WorkspaceGetRequest, WorkspaceGetResult, "workspace.get"), diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -163,45 +163,45 @@ impl TargetCommand { }, FarmCommand::Publish => "farm.publish", }, - Self::Listing(args) => match args.command { - ListingCommand::Create => "listing.create", - ListingCommand::Get => "listing.get", + Self::Listing(args) => match &args.command { + ListingCommand::Create(_) => "listing.create", + ListingCommand::Get(_) => "listing.get", ListingCommand::List => "listing.list", - ListingCommand::Update => "listing.update", - ListingCommand::Validate => "listing.validate", - ListingCommand::Publish => "listing.publish", - ListingCommand::Archive => "listing.archive", + ListingCommand::Update(_) => "listing.update", + ListingCommand::Validate(_) => "listing.validate", + ListingCommand::Publish(_) => "listing.publish", + ListingCommand::Archive(_) => "listing.archive", }, Self::Market(args) => match &args.command { MarketCommand::Refresh => "market.refresh", - MarketCommand::Product(product) => match product.command { - MarketProductCommand::Search => "market.product.search", + MarketCommand::Product(product) => match &product.command { + MarketProductCommand::Search(_) => "market.product.search", }, - MarketCommand::Listing(listing) => match listing.command { - MarketListingCommand::Get => "market.listing.get", + MarketCommand::Listing(listing) => match &listing.command { + MarketListingCommand::Get(_) => "market.listing.get", }, }, Self::Basket(args) => match &args.command { - BasketCommand::Create => "basket.create", - BasketCommand::Get => "basket.get", + BasketCommand::Create(_) => "basket.create", + BasketCommand::Get(_) => "basket.get", BasketCommand::List => "basket.list", BasketCommand::Item(item) => match item.command { - BasketItemCommand::Add => "basket.item.add", - BasketItemCommand::Update => "basket.item.update", - BasketItemCommand::Remove => "basket.item.remove", + BasketItemCommand::Add(_) => "basket.item.add", + BasketItemCommand::Update(_) => "basket.item.update", + BasketItemCommand::Remove(_) => "basket.item.remove", }, - BasketCommand::Validate => "basket.validate", + BasketCommand::Validate(_) => "basket.validate", BasketCommand::Quote(quote) => match quote.command { - BasketQuoteCommand::Create => "basket.quote.create", + BasketQuoteCommand::Create(_) => "basket.quote.create", }, }, Self::Order(args) => match &args.command { - OrderCommand::Submit => "order.submit", - OrderCommand::Get => "order.get", + OrderCommand::Submit(_) => "order.submit", + OrderCommand::Get(_) => "order.get", OrderCommand::List => "order.list", - OrderCommand::Event(event) => match event.command { - OrderEventCommand::List => "order.event.list", - OrderEventCommand::Watch => "order.event.watch", + OrderCommand::Event(event) => match &event.command { + OrderEventCommand::List(_) => "order.event.list", + OrderEventCommand::Watch(_) => "order.event.watch", }, }, } @@ -517,15 +517,57 @@ pub struct ListingArgs { pub command: ListingCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum ListingCommand { - Create, - Get, + Create(ListingCreateArgs), + Get(LookupArgs), List, - Update, - Validate, - Publish, - Archive, + Update(FileArgs), + Validate(FileArgs), + Publish(FileArgs), + Archive(FileArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct ListingCreateArgs { + #[arg(long)] + pub output: Option<PathBuf>, + #[arg(long)] + pub key: Option<String>, + #[arg(long)] + pub title: Option<String>, + #[arg(long)] + pub category: Option<String>, + #[arg(long)] + pub summary: Option<String>, + #[arg(long = "bin-id")] + pub bin_id: Option<String>, + #[arg(long = "quantity-amount")] + pub quantity_amount: Option<String>, + #[arg(long = "quantity-unit")] + pub quantity_unit: Option<String>, + #[arg(long = "price-amount")] + pub price_amount: Option<String>, + #[arg(long = "price-currency")] + pub price_currency: Option<String>, + #[arg(long = "price-per-amount")] + pub price_per_amount: Option<String>, + #[arg(long = "price-per-unit")] + pub price_per_unit: Option<String>, + #[arg(long)] + pub available: Option<String>, + #[arg(long)] + pub label: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct FileArgs { + pub file: Option<PathBuf>, +} + +#[derive(Debug, Clone, Args)] +pub struct LookupArgs { + pub key: Option<String>, } #[derive(Debug, Clone, Args)] @@ -547,9 +589,9 @@ pub struct MarketProductArgs { pub command: MarketProductCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum MarketProductCommand { - Search, + Search(QueryArgs), } #[derive(Debug, Clone, Args)] @@ -558,9 +600,14 @@ pub struct MarketListingArgs { pub command: MarketListingCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum MarketListingCommand { - Get, + Get(LookupArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct QueryArgs { + pub query: Vec<String>, } #[derive(Debug, Clone, Args)] @@ -571,25 +618,64 @@ pub struct BasketArgs { #[derive(Debug, Clone, Subcommand)] pub enum BasketCommand { - Create, - Get, + Create(BasketCreateArgs), + Get(BasketKeyArgs), List, Item(BasketItemArgs), - Validate, + Validate(BasketKeyArgs), Quote(BasketQuoteArgs), } #[derive(Debug, Clone, Args)] +pub struct BasketCreateArgs { + pub basket_id: Option<String>, + #[arg(long)] + pub listing: Option<String>, + #[arg(long = "listing-addr")] + pub listing_addr: Option<String>, + #[arg(long = "bin-id")] + pub bin_id: Option<String>, + #[arg(long)] + pub quantity: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct BasketKeyArgs { + pub basket_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct BasketItemArgs { #[command(subcommand)] pub command: BasketItemCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum BasketItemCommand { - Add, - Update, - Remove, + Add(BasketItemMutationArgs), + Update(BasketItemMutationArgs), + Remove(BasketItemRemoveArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct BasketItemMutationArgs { + pub basket_id: Option<String>, + #[arg(long = "item-id")] + pub item_id: Option<String>, + #[arg(long)] + pub listing: Option<String>, + #[arg(long = "listing-addr")] + pub listing_addr: Option<String>, + #[arg(long = "bin-id")] + pub bin_id: Option<String>, + #[arg(long)] + pub quantity: Option<String>, +} + +#[derive(Debug, Clone, Args)] +pub struct BasketItemRemoveArgs { + pub basket_id: Option<String>, + pub item_id: Option<String>, } #[derive(Debug, Clone, Args)] @@ -598,9 +684,9 @@ pub struct BasketQuoteArgs { pub command: BasketQuoteCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum BasketQuoteCommand { - Create, + Create(BasketKeyArgs), } #[derive(Debug, Clone, Args)] @@ -611,22 +697,34 @@ pub struct OrderArgs { #[derive(Debug, Clone, Subcommand)] pub enum OrderCommand { - Submit, - Get, + Submit(OrderSubmitArgs), + Get(OrderKeyArgs), List, Event(OrderEventArgs), } #[derive(Debug, Clone, Args)] +pub struct OrderSubmitArgs { + pub order_id: Option<String>, + #[arg(long)] + pub watch: bool, +} + +#[derive(Debug, Clone, Args)] +pub struct OrderKeyArgs { + pub order_id: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderEventArgs { #[command(subcommand)] pub command: OrderEventCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum OrderEventCommand { - List, - Watch, + List(OrderKeyArgs), + Watch(OrderKeyArgs), } #[derive(Debug, Clone, Args)] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2,11 +2,34 @@ use std::process::Command; use assert_cmd::prelude::*; use serde_json::Value; +use tempfile::TempDir; + +const LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; fn radroots() -> Command { Command::cargo_bin("radroots").expect("binary") } +fn radroots_in(root: &TempDir) -> Command { + let mut command = radroots(); + command.env("RADROOTS_CLI_PATHS_PROFILE", "repo_local"); + command.env("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", root.path()); + command +} + +fn json_success(root: &TempDir, args: &[&str]) -> Value { + let output = radroots_in(root).args(args).output().expect("run command"); + + assert!( + output.status.success(), + "`{args:?}` failed with stderr `{}` and stdout `{}`", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stdout).expect("json envelope") +} + #[test] fn root_help_exposes_only_mvp_namespaces() { let output = radroots().arg("--help").output().expect("run root help"); @@ -135,3 +158,139 @@ fn required_approval_missing_token_returns_structured_error() { assert_eq!(value["errors"][0]["code"], "approval_required"); assert_eq!(value["errors"][0]["exit_code"], 6); } + +#[test] +fn buyer_mvp_flow_acceptance_uses_target_operations() { + let root = TempDir::new().expect("tempdir"); + + let search = json_success( + &root, + &["--format", "json", "market", "product", "search", "eggs"], + ); + assert_eq!(search["operation_id"], "market.product.search"); + assert_eq!(search["errors"].as_array().expect("errors").len(), 0); + + let create = json_success( + &root, + &["--format", "json", "basket", "create", "basket_flow"], + ); + assert_eq!(create["operation_id"], "basket.create"); + assert_eq!(create["result"]["basket_id"], "basket_flow"); + + let add = json_success( + &root, + &[ + "--format", + "json", + "basket", + "item", + "add", + "basket_flow", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ], + ); + assert_eq!(add["operation_id"], "basket.item.add"); + assert_eq!(add["result"]["ready_for_quote"], true); + + let quote = json_success( + &root, + &[ + "--format", + "json", + "basket", + "quote", + "create", + "basket_flow", + ], + ); + assert_eq!(quote["operation_id"], "basket.quote.create"); + assert_eq!(quote["result"]["state"], "quoted"); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + + let submit = json_success( + &root, + &["--format", "json", "--dry-run", "order", "submit", order_id], + ); + assert_eq!(submit["operation_id"], "order.submit"); + assert_eq!(submit["dry_run"], true); + assert_eq!(submit["errors"].as_array().expect("errors").len(), 0); +} + +#[test] +fn seller_mvp_flow_acceptance_uses_target_operations() { + let root = TempDir::new().expect("tempdir"); + let listing_file = root.path().join("listing.toml"); + let listing_file = listing_file.to_string_lossy().into_owned(); + + let create = json_success( + &root, + &[ + "--format", + "json", + "listing", + "create", + "--output", + listing_file.as_str(), + "--key", + "eggs", + "--title", + "Eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "dozen", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "dozen", + "--available", + "10", + ], + ); + assert_eq!(create["operation_id"], "listing.create"); + assert_eq!(create["result"]["file"], listing_file); + + let validate = json_success( + &root, + &[ + "--format", + "json", + "listing", + "validate", + listing_file.as_str(), + ], + ); + assert_eq!(validate["operation_id"], "listing.validate"); + assert!(validate["result"]["valid"].is_boolean()); + + let publish = json_success( + &root, + &[ + "--format", + "json", + "--dry-run", + "listing", + "publish", + listing_file.as_str(), + ], + ); + assert_eq!(publish["operation_id"], "listing.publish"); + assert_eq!(publish["result"]["state"], "dry_run"); + + let orders = json_success(&root, &["--format", "json", "order", "list"]); + assert_eq!(orders["operation_id"], "order.list"); + assert_eq!(orders["errors"].as_array().expect("errors").len(), 0); +}