cli

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

commit be4c11b73627c1f7f5509fdf81c27c3340e7e9a8
parent ca46d9d1f6bd2964b3daa3d7e9078b16e82bc16a
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 03:05:04 +0000

cli: add local basket operation service

- add adapter-backed basket create, get, list, item mutation, validate, and quote operations
- persist deterministic local basket state under the CLI app data root
- materialize basket quotes as existing order drafts without adding basket planning
- cover basket persistence, item mutation, validation, quote, and dry-run behavior with focused tests

Diffstat:
Msrc/main.rs | 1+
Asrc/operation_basket.rs | 1175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 1176 insertions(+), 0 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod commands; mod domain; mod operation_adapter; +mod operation_basket; mod operation_core; mod operation_farm; mod operation_listing; diff --git a/src/operation_basket.rs b/src/operation_basket.rs @@ -0,0 +1,1175 @@ +#![allow(dead_code)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::cli::OrderNewArgs; +use crate::domain::runtime::OrderNewView; +use crate::operation_adapter::{ + BasketCreateRequest, BasketCreateResult, BasketGetRequest, BasketGetResult, + BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, BasketItemRemoveResult, + BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest, BasketListResult, + BasketQuoteCreateRequest, BasketQuoteCreateResult, BasketValidateRequest, BasketValidateResult, + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +const BASKET_KIND: &str = "basket_v1"; +const BASKET_SOURCE: &str = "local baskets - local first"; +const BASKET_QUOTE_SOURCE: &str = "local baskets - deterministic quote"; +const BASKETS_DIR: &str = "baskets"; + +static BASKET_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BasketDocument { + version: u32, + kind: String, + basket: BasketState, + #[serde(default, skip_serializing_if = "Option::is_none")] + quote: Option<BasketQuote>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BasketState { + basket_id: String, + created_at_unix: u64, + updated_at_unix: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + items: Vec<BasketItem>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BasketItem { + item_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + listing: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + listing_addr: Option<String>, + bin_id: String, + quantity: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BasketQuote { + quote_id: String, + order_id: String, + order_file: String, + ready_for_submit: bool, + created_at_unix: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + issues: Vec<BasketIssue>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BasketIssue { + field: String, + message: String, +} + +#[derive(Debug, Clone)] +struct LoadedBasket { + file: PathBuf, + document: BasketDocument, +} + +pub struct BasketOperationService<'a> { + config: &'a RuntimeConfig, +} + +impl<'a> BasketOperationService<'a> { + pub fn new(config: &'a RuntimeConfig) -> Self { + Self { config } + } +} + +impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { + type Result = BasketCreateResult; + + fn execute( + &self, + request: OperationRequest<BasketCreateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = string_input(&request, "basket_id").unwrap_or_else(next_basket_id); + let initial_item = optional_item_from_request(&request, None)?; + if request.context.dry_run { + return json_operation_result::<BasketCreateResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "item_count": initial_item.as_ref().map(|_| 1).unwrap_or(0), + "actions": ["radroots basket create"], + })); + } + + let file = basket_lookup_path(self.config, basket_id.as_str()); + if file.exists() { + return Err(invalid_input( + request.operation_id(), + format!("basket `{basket_id}` already exists"), + )); + } + + let now = now_unix(); + let document = BasketDocument { + version: 1, + kind: BASKET_KIND.to_owned(), + basket: BasketState { + basket_id, + created_at_unix: now, + updated_at_unix: now, + items: initial_item.into_iter().collect(), + }, + quote: None, + }; + save_basket(file.as_path(), &document)?; + json_operation_result::<BasketCreateResult>(basket_view(&document, file.as_path(), None)) + } +} + +impl OperationService<BasketGetRequest> for BasketOperationService<'_> { + type Result = BasketGetResult; + + fn execute( + &self, + request: OperationRequest<BasketGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let lookup = required_basket_id(&request)?; + let Some(loaded) = load_basket_optional(self.config, lookup.as_str())? else { + return json_operation_result::<BasketGetResult>(missing_basket_view( + self.config, + lookup.as_str(), + )); + }; + json_operation_result::<BasketGetResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + None, + )) + } +} + +impl OperationService<BasketListRequest> for BasketOperationService<'_> { + type Result = BasketListResult; + + fn execute( + &self, + _request: OperationRequest<BasketListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let baskets = list_basket_summaries(self.config)?; + json_operation_result::<BasketListResult>(json!({ + "state": if baskets.is_empty() { "empty" } else { "ready" }, + "source": BASKET_SOURCE, + "count": baskets.len(), + "baskets": baskets, + "actions": if baskets.is_empty() { + vec!["radroots basket create".to_owned()] + } else { + Vec::new() + }, + })) + } +} + +impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> { + type Result = BasketItemAddResult; + + fn execute( + &self, + request: OperationRequest<BasketItemAddRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let item = required_item_from_request(&request, Some(next_item_id(&loaded.document)))?; + if request.context.dry_run { + return json_operation_result::<BasketItemAddResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "item": item, + "actions": ["radroots basket item add"], + })); + } + + loaded.document.basket.items.push(item); + touch_basket(&mut loaded.document); + loaded.document.quote = None; + save_basket(loaded.file.as_path(), &loaded.document)?; + json_operation_result::<BasketItemAddResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + Some("updated"), + )) + } +} + +impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> { + type Result = BasketItemUpdateResult; + + fn execute( + &self, + request: OperationRequest<BasketItemUpdateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let item_id = required_string(&request, "item_id")?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let Some(index) = loaded + .document + .basket + .items + .iter() + .position(|item| item.item_id == item_id) + else { + return Err(invalid_input( + request.operation_id(), + format!("basket item `{item_id}` was not found"), + )); + }; + + let updated = + update_item_from_request(&request, loaded.document.basket.items[index].clone())?; + if request.context.dry_run { + return json_operation_result::<BasketItemUpdateResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "item": updated, + "actions": ["radroots basket item update"], + })); + } + + loaded.document.basket.items[index] = updated; + touch_basket(&mut loaded.document); + loaded.document.quote = None; + save_basket(loaded.file.as_path(), &loaded.document)?; + json_operation_result::<BasketItemUpdateResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + Some("updated"), + )) + } +} + +impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> { + type Result = BasketItemRemoveResult; + + fn execute( + &self, + request: OperationRequest<BasketItemRemoveRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let item_id = required_string(&request, "item_id")?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let Some(index) = loaded + .document + .basket + .items + .iter() + .position(|item| item.item_id == item_id) + else { + return Err(invalid_input( + request.operation_id(), + format!("basket item `{item_id}` was not found"), + )); + }; + + if request.context.dry_run { + return json_operation_result::<BasketItemRemoveResult>(json!({ + "state": "dry_run", + "source": BASKET_SOURCE, + "basket_id": basket_id, + "item_id": item_id, + "actions": ["radroots basket item remove"], + })); + } + + loaded.document.basket.items.remove(index); + touch_basket(&mut loaded.document); + loaded.document.quote = None; + save_basket(loaded.file.as_path(), &loaded.document)?; + json_operation_result::<BasketItemRemoveResult>(basket_view( + &loaded.document, + loaded.file.as_path(), + Some("updated"), + )) + } +} + +impl OperationService<BasketValidateRequest> for BasketOperationService<'_> { + type Result = BasketValidateResult; + + fn execute( + &self, + request: OperationRequest<BasketValidateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let Some(loaded) = load_basket_optional(self.config, basket_id.as_str())? else { + return json_operation_result::<BasketValidateResult>(missing_basket_view( + self.config, + basket_id.as_str(), + )); + }; + json_operation_result::<BasketValidateResult>(basket_validation_view( + &loaded.document, + loaded.file.as_path(), + )) + } +} + +impl OperationService<BasketQuoteCreateRequest> for BasketOperationService<'_> { + type Result = BasketQuoteCreateResult; + + fn execute( + &self, + request: OperationRequest<BasketQuoteCreateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let basket_id = required_basket_id(&request)?; + let mut loaded = + load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; + let issues = basket_issues(&loaded.document); + if !issues.is_empty() { + return json_operation_result::<BasketQuoteCreateResult>(json!({ + "state": "unconfigured", + "source": BASKET_QUOTE_SOURCE, + "basket_id": basket_id, + "file": loaded.file.display().to_string(), + "ready_for_quote": false, + "issues": issues, + "actions": basket_actions(&loaded.document), + })); + } + + let item = loaded + .document + .basket + .items + .first() + .expect("validated basket has one item") + .clone(); + if request.context.dry_run { + return json_operation_result::<BasketQuoteCreateResult>(json!({ + "state": "dry_run", + "source": BASKET_QUOTE_SOURCE, + "basket_id": basket_id, + "item": item, + "actions": ["radroots basket quote create"], + })); + } + + let order = map_runtime(crate::runtime::order::scaffold( + self.config, + &OrderNewArgs { + listing: item.listing.clone(), + listing_addr: item.listing_addr.clone(), + bin_id: Some(item.bin_id.clone()), + bin_count: Some(item.quantity), + }, + ))?; + let quote = BasketQuote { + quote_id: format!("quote_{}", loaded.document.basket.basket_id), + order_id: order.order_id.clone(), + order_file: order.file.clone(), + ready_for_submit: order.ready_for_submit, + created_at_unix: now_unix(), + issues: quote_issues_from_order(&order), + }; + loaded.document.quote = Some(quote.clone()); + touch_basket(&mut loaded.document); + save_basket(loaded.file.as_path(), &loaded.document)?; + + json_operation_result::<BasketQuoteCreateResult>(json!({ + "state": "quoted", + "source": BASKET_QUOTE_SOURCE, + "basket_id": loaded.document.basket.basket_id, + "file": loaded.file.display().to_string(), + "quote": quote, + "order": order, + "actions": quote_actions(&order), + })) + } +} + +fn optional_item_from_request<P>( + request: &OperationRequest<P>, + item_id: Option<String>, +) -> Result<Option<BasketItem>, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + if string_input(request, "listing").is_none() + && string_input(request, "listing_addr").is_none() + && string_input(request, "bin_id").is_none() + { + return Ok(None); + } + required_item_from_request(request, item_id).map(Some) +} + +fn required_item_from_request<P>( + request: &OperationRequest<P>, + item_id: Option<String>, +) -> Result<BasketItem, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + let listing = string_input(request, "listing"); + let listing_addr = string_input(request, "listing_addr"); + if listing.is_none() && listing_addr.is_none() { + return Err(invalid_input( + request.operation_id(), + "missing required `listing` or `listing_addr` input".to_owned(), + )); + } + let bin_id = required_string(request, "bin_id")?; + let quantity = quantity_input(request)?.unwrap_or(1); + if quantity == 0 { + return Err(invalid_input( + request.operation_id(), + "`quantity` must be greater than 0".to_owned(), + )); + } + + Ok(BasketItem { + item_id: item_id + .or_else(|| string_input(request, "item_id")) + .unwrap_or_else(|| "item_1".to_owned()), + listing, + listing_addr, + bin_id, + quantity, + }) +} + +fn update_item_from_request<P>( + request: &OperationRequest<P>, + mut item: BasketItem, +) -> Result<BasketItem, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + let mut changed = false; + if let Some(listing) = string_input(request, "listing") { + item.listing = Some(listing); + changed = true; + } + if let Some(listing_addr) = string_input(request, "listing_addr") { + item.listing_addr = Some(listing_addr); + changed = true; + } + if let Some(bin_id) = string_input(request, "bin_id") { + item.bin_id = bin_id; + changed = true; + } + if let Some(quantity) = quantity_input(request)? { + if quantity == 0 { + return Err(invalid_input( + request.operation_id(), + "`quantity` must be greater than 0".to_owned(), + )); + } + item.quantity = quantity; + changed = true; + } + if !changed { + return Err(invalid_input( + request.operation_id(), + "no item update input was provided".to_owned(), + )); + } + Ok(item) +} + +fn basket_view(document: &BasketDocument, file: &Path, state: Option<&str>) -> Value { + json!({ + "state": state.unwrap_or("ready"), + "source": BASKET_SOURCE, + "basket_id": document.basket.basket_id, + "file": file.display().to_string(), + "item_count": document.basket.items.len(), + "items": document.basket.items, + "quote": document.quote, + "ready_for_quote": basket_issues(document).is_empty(), + "issues": basket_issues(document), + "actions": basket_actions(document), + }) +} + +fn basket_validation_view(document: &BasketDocument, file: &Path) -> Value { + let issues = basket_issues(document); + json!({ + "state": if issues.is_empty() { "ready" } else { "unconfigured" }, + "source": BASKET_SOURCE, + "basket_id": document.basket.basket_id, + "file": file.display().to_string(), + "ready_for_quote": issues.is_empty(), + "item_count": document.basket.items.len(), + "issues": issues, + "actions": basket_actions(document), + }) +} + +fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value { + json!({ + "state": "missing", + "source": BASKET_SOURCE, + "lookup": lookup, + "file": basket_lookup_path(config, lookup).display().to_string(), + "reason": format!("basket `{lookup}` was not found"), + "actions": ["radroots basket list", "radroots basket create"], + }) +} + +fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, OperationAdapterError> { + let dir = baskets_dir(config); + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut baskets = Vec::new(); + for entry in fs::read_dir(&dir).map_err(|error| { + OperationAdapterError::Runtime(format!("read basket directory {}: {error}", dir.display())) + })? { + let entry = entry.map_err(|error| { + OperationAdapterError::Runtime(format!( + "read basket directory {}: {error}", + dir.display() + )) + })?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let loaded = load_basket_path(path.as_path())?; + baskets.push(json!({ + "basket_id": loaded.document.basket.basket_id, + "state": if basket_issues(&loaded.document).is_empty() { "ready" } else { "unconfigured" }, + "file": loaded.file.display().to_string(), + "item_count": loaded.document.basket.items.len(), + "ready_for_quote": basket_issues(&loaded.document).is_empty(), + "quote": loaded.document.quote, + "updated_at_unix": loaded.document.basket.updated_at_unix, + })); + } + baskets.sort_by(|left, right| { + right["updated_at_unix"] + .as_u64() + .cmp(&left["updated_at_unix"].as_u64()) + .then_with(|| { + left["basket_id"] + .as_str() + .unwrap_or_default() + .cmp(right["basket_id"].as_str().unwrap_or_default()) + }) + }); + Ok(baskets) +} + +fn basket_issues(document: &BasketDocument) -> Vec<BasketIssue> { + let mut issues = Vec::new(); + if document.basket.items.is_empty() { + issues.push(BasketIssue { + field: "basket.items".to_owned(), + message: "basket must contain one item before quote creation".to_owned(), + }); + } + if document.basket.items.len() > 1 { + issues.push(BasketIssue { + field: "basket.items".to_owned(), + message: "MVP basket quotes support exactly one item".to_owned(), + }); + } + for item in &document.basket.items { + if item.listing.is_none() && item.listing_addr.is_none() { + issues.push(BasketIssue { + field: format!("basket.items.{}.listing", item.item_id), + message: "item must include listing or listing_addr".to_owned(), + }); + } + if item.bin_id.trim().is_empty() { + issues.push(BasketIssue { + field: format!("basket.items.{}.bin_id", item.item_id), + message: "item must include bin_id".to_owned(), + }); + } + if item.quantity == 0 { + issues.push(BasketIssue { + field: format!("basket.items.{}.quantity", item.item_id), + message: "item quantity must be greater than 0".to_owned(), + }); + } + } + issues +} + +fn basket_actions(document: &BasketDocument) -> Vec<String> { + let basket_id = document.basket.basket_id.as_str(); + if document.basket.items.is_empty() { + return vec![format!("radroots basket item add {basket_id}")]; + } + if basket_issues(document).is_empty() { + vec![ + format!("radroots basket validate {basket_id}"), + format!("radroots basket quote create {basket_id}"), + ] + } else { + vec![format!("radroots basket get {basket_id}")] + } +} + +fn quote_actions(order: &OrderNewView) -> Vec<String> { + if order.ready_for_submit { + vec![format!("radroots order submit {}", order.order_id)] + } else { + let mut actions = vec![format!("radroots order get {}", order.order_id)]; + actions.extend(order.actions.iter().map(|action| match action.as_str() { + "radroots account new" | "radroots account create" => { + "radroots account create".to_owned() + } + other => other.to_owned(), + })); + actions + } +} + +fn quote_issues_from_order(order: &OrderNewView) -> Vec<BasketIssue> { + order + .issues + .iter() + .map(|issue| BasketIssue { + field: issue.field.clone(), + message: issue.message.clone(), + }) + .collect() +} + +fn load_required_basket( + config: &RuntimeConfig, + lookup: &str, + operation_id: &str, +) -> Result<LoadedBasket, OperationAdapterError> { + load_basket_optional(config, lookup)?.ok_or_else(|| { + invalid_input( + operation_id, + format!("basket `{lookup}` was not found; run `radroots basket create` first"), + ) + }) +} + +fn load_basket_optional( + config: &RuntimeConfig, + lookup: &str, +) -> Result<Option<LoadedBasket>, OperationAdapterError> { + let path = basket_lookup_path(config, lookup); + if !path.exists() { + return Ok(None); + } + load_basket_path(path.as_path()).map(Some) +} + +fn load_basket_path(path: &Path) -> Result<LoadedBasket, OperationAdapterError> { + let contents = fs::read_to_string(path).map_err(|error| { + OperationAdapterError::Runtime(format!("read basket {}: {error}", path.display())) + })?; + let document = serde_json::from_str::<BasketDocument>(contents.as_str()).map_err(|error| { + OperationAdapterError::Runtime(format!("parse basket {}: {error}", path.display())) + })?; + Ok(LoadedBasket { + file: path.to_path_buf(), + document, + }) +} + +fn save_basket(path: &Path, document: &BasketDocument) -> Result<(), OperationAdapterError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + OperationAdapterError::Runtime(format!( + "create basket directory {}: {error}", + parent.display() + )) + })?; + } + let contents = serde_json::to_string_pretty(document) + .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; + fs::write(path, contents).map_err(|error| { + OperationAdapterError::Runtime(format!("write basket {}: {error}", path.display())) + }) +} + +fn baskets_dir(config: &RuntimeConfig) -> PathBuf { + config.paths.app_data_root.join(BASKETS_DIR) +} + +fn basket_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { + let candidate = PathBuf::from(lookup); + if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { + return candidate; + } + let file_name = if lookup.ends_with(".json") { + lookup.to_owned() + } else { + format!("{lookup}.json") + }; + baskets_dir(config).join(file_name) +} + +fn touch_basket(document: &mut BasketDocument) { + document.basket.updated_at_unix = now_unix(); +} + +fn next_item_id(document: &BasketDocument) -> String { + for index in 1.. { + let candidate = format!("item_{index}"); + if document + .basket + .items + .iter() + .all(|item| item.item_id != candidate) + { + return candidate; + } + } + unreachable!("unbounded item id search should always return") +} + +fn next_basket_id() -> String { + let sequence = BASKET_COUNTER.fetch_add(1, Ordering::Relaxed) + 1; + format!("basket_{}_{}", now_unix(), sequence) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn required_basket_id<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, "basket_id") + .or_else(|| string_input(request, "key")) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "missing required `basket_id` input".to_owned(), + ) + }) +} + +fn required_string<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key).ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("missing required `{key}` input"), + ) + }) +} + +fn quantity_input<P>(request: &OperationRequest<P>) -> Result<Option<u32>, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + let value = request + .payload + .input() + .get("quantity") + .or_else(|| request.payload.input().get("bin_count")); + let Some(value) = value else { + return Ok(None); + }; + match value { + Value::Number(number) => number + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .map(Some) + .ok_or_else(|| { + invalid_input( + request.operation_id(), + "`quantity` input must fit in u32".to_owned(), + ) + }), + Value::String(value) => value.parse::<u32>().map(Some).map_err(|error| { + invalid_input( + request.operation_id(), + format!("`quantity` input must be a u32: {error}"), + ) + }), + _ => Err(invalid_input( + request.operation_id(), + "`quantity` input must be a number or string".to_owned(), + )), + } +} + +fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + OperationResult::new(R::from_value(value)) +} + +fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +} + +fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { + OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + use serde_json::{Map, Value}; + use tempfile::tempdir; + + use super::BasketOperationService; + use crate::operation_adapter::{ + BasketCreateRequest, BasketGetRequest, BasketItemAddRequest, BasketItemRemoveRequest, + BasketItemUpdateRequest, BasketListRequest, BasketQuoteCreateRequest, + BasketValidateRequest, OperationAdapter, OperationContext, OperationData, OperationRequest, + }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; + + const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + + #[test] + fn basket_service_creates_gets_and_lists_local_baskets() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(BasketOperationService::new(&config)); + let create = OperationRequest::new( + OperationContext::default(), + BasketCreateRequest::from_data(data(&[("basket_id", "basket_test")])), + ) + .expect("basket create request"); + let create_envelope = service + .execute(create) + .expect("basket create result") + .to_envelope(OperationContext::default().envelope_context("req_basket_create")) + .expect("basket create envelope"); + assert_eq!(create_envelope.operation_id, "basket.create"); + assert_eq!(create_envelope.result["basket_id"], "basket_test"); + assert_eq!(create_envelope.result["item_count"], 0); + + let get = OperationRequest::new( + OperationContext::default(), + BasketGetRequest::from_data(data(&[("basket_id", "basket_test")])), + ) + .expect("basket get request"); + let get_envelope = service + .execute(get) + .expect("basket get result") + .to_envelope(OperationContext::default().envelope_context("req_basket_get")) + .expect("basket get envelope"); + assert_eq!(get_envelope.operation_id, "basket.get"); + assert_eq!(get_envelope.result["state"], "ready"); + + let list = OperationRequest::new(OperationContext::default(), BasketListRequest::default()) + .expect("basket list request"); + let list_envelope = service + .execute(list) + .expect("basket list result") + .to_envelope(OperationContext::default().envelope_context("req_basket_list")) + .expect("basket list envelope"); + assert_eq!(list_envelope.operation_id, "basket.list"); + assert_eq!(list_envelope.result["count"], 1); + } + + #[test] + fn basket_service_mutates_items_and_validates_readiness() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(BasketOperationService::new(&config)); + create_basket(&service, "basket_items"); + + let add = OperationRequest::new( + OperationContext::default(), + BasketItemAddRequest::from_data(data(&[ + ("basket_id", "basket_items"), + ("listing_addr", LISTING_ADDR), + ("bin_id", "bin-1"), + ("quantity", "2"), + ])), + ) + .expect("basket item add request"); + let add_envelope = service + .execute(add) + .expect("basket item add result") + .to_envelope(OperationContext::default().envelope_context("req_basket_add")) + .expect("basket item add envelope"); + assert_eq!(add_envelope.operation_id, "basket.item.add"); + assert_eq!(add_envelope.result["item_count"], 1); + + let update = OperationRequest::new( + OperationContext::default(), + BasketItemUpdateRequest::from_data(data(&[ + ("basket_id", "basket_items"), + ("item_id", "item_1"), + ("quantity", "3"), + ])), + ) + .expect("basket item update request"); + let update_envelope = service + .execute(update) + .expect("basket item update result") + .to_envelope(OperationContext::default().envelope_context("req_basket_update")) + .expect("basket item update envelope"); + assert_eq!(update_envelope.operation_id, "basket.item.update"); + assert_eq!(update_envelope.result["items"][0]["quantity"], 3); + + let validate = OperationRequest::new( + OperationContext::default(), + BasketValidateRequest::from_data(data(&[("basket_id", "basket_items")])), + ) + .expect("basket validate request"); + let validate_envelope = service + .execute(validate) + .expect("basket validate result") + .to_envelope(OperationContext::default().envelope_context("req_basket_validate")) + .expect("basket validate envelope"); + assert_eq!(validate_envelope.operation_id, "basket.validate"); + assert_eq!(validate_envelope.result["ready_for_quote"], true); + + let remove = OperationRequest::new( + OperationContext::default(), + BasketItemRemoveRequest::from_data(data(&[ + ("basket_id", "basket_items"), + ("item_id", "item_1"), + ])), + ) + .expect("basket item remove request"); + let remove_envelope = service + .execute(remove) + .expect("basket item remove result") + .to_envelope(OperationContext::default().envelope_context("req_basket_remove")) + .expect("basket item remove envelope"); + assert_eq!(remove_envelope.operation_id, "basket.item.remove"); + assert_eq!(remove_envelope.result["item_count"], 0); + assert_eq!(remove_envelope.result["ready_for_quote"], false); + } + + #[test] + fn basket_quote_create_materializes_order_draft() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(BasketOperationService::new(&config)); + create_basket(&service, "basket_quote"); + add_listing_item(&service, "basket_quote"); + + let quote = OperationRequest::new( + OperationContext::default(), + BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_quote")])), + ) + .expect("basket quote request"); + let envelope = service + .execute(quote) + .expect("basket quote result") + .to_envelope(OperationContext::default().envelope_context("req_basket_quote")) + .expect("basket quote envelope"); + + assert_eq!(envelope.operation_id, "basket.quote.create"); + assert_eq!(envelope.result["state"], "quoted"); + assert!( + envelope.result["quote"]["order_id"] + .as_str() + .unwrap() + .starts_with("ord_") + ); + assert!(PathBuf::from(envelope.result["quote"]["order_file"].as_str().unwrap()).exists()); + } + + #[test] + fn basket_quote_create_dry_run_skips_order_draft() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let service = OperationAdapter::new(BasketOperationService::new(&config)); + create_basket(&service, "basket_dry_run"); + add_listing_item(&service, "basket_dry_run"); + + let mut context = OperationContext::default(); + context.dry_run = true; + let quote = OperationRequest::new( + context.clone(), + BasketQuoteCreateRequest::from_data(data(&[("basket_id", "basket_dry_run")])), + ) + .expect("basket quote request"); + let envelope = service + .execute(quote) + .expect("basket quote dry run") + .to_envelope(context.envelope_context("req_basket_quote")) + .expect("basket quote envelope"); + + assert_eq!(envelope.operation_id, "basket.quote.create"); + assert_eq!(envelope.dry_run, true); + assert_eq!(envelope.result["state"], "dry_run"); + assert!(envelope.result.get("order").is_none()); + } + + fn create_basket(service: &OperationAdapter<BasketOperationService<'_>>, basket_id: &str) { + let request = OperationRequest::new( + OperationContext::default(), + BasketCreateRequest::from_data(data(&[("basket_id", basket_id)])), + ) + .expect("basket create request"); + service.execute(request).expect("basket create result"); + } + + fn add_listing_item(service: &OperationAdapter<BasketOperationService<'_>>, basket_id: &str) { + let request = OperationRequest::new( + OperationContext::default(), + BasketItemAddRequest::from_data(data(&[ + ("basket_id", basket_id), + ("listing_addr", LISTING_ADDR), + ("bin_id", "bin-1"), + ("quantity", "1"), + ])), + ) + .expect("basket item add request"); + service.execute(request).expect("basket item add result"); + } + + fn sample_config(root: &Path) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } + + fn data(entries: &[(&str, &str)]) -> OperationData { + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) + .collect::<Map<String, Value>>() + } +}