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:
| M | src/operation_adapter.rs | | | 143 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/target_cli.rs | | | 194 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- |
| M | tests/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 "e.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);
+}