cli

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

commit 4a6618dc1528de5f09cd55434b9ad6524df6579a
parent 42036881bca6cca83280477ad78ca1aeeed5f516
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 21:36:27 +0000

implement human first sell wrappers

Diffstat:
Msrc/cli.rs | 49++++++++++++++++++++++++++++++++++++++++++-------
Msrc/commands/mod.rs | 27+++++++++------------------
Asrc/commands/sell.rs | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/runtime.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 286++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/runtime/listing.rs | 630+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Atests/sell.rs | 413+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1665 insertions(+), 53 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -75,7 +75,7 @@ Global options Examples radroots setup seller radroots market search eggs - radroots sell check ./listing.toml + radroots sell add tomatoes radroots order create --listing sf-tomatoes --bin bin-1 --qty 2 "; @@ -129,7 +129,7 @@ Compatibility paths: `sync pull`, `find`, and `listing get` remain available. const SELL_HELP: &str = "\ Examples: - radroots sell add --output ./listing.toml --title Tomatoes + radroots sell add tomatoes --pack \"1 kg\" --price \"10 USD/kg\" --stock 25 radroots sell check ./listing.toml radroots sell publish ./listing.toml @@ -1132,10 +1132,29 @@ pub struct SellArgs { pub command: SellCommand, } +#[derive(Debug, Clone, Args)] +pub struct SellAddArgs { + pub product: String, + #[arg(long)] + pub file: Option<PathBuf>, + #[arg(long)] + pub title: Option<String>, + #[arg(long)] + pub category: Option<String>, + #[arg(long)] + pub summary: Option<String>, + #[arg(long = "pack")] + pub pack: Option<String>, + #[arg(long = "price")] + pub price_expr: Option<String>, + #[arg(long = "stock")] + pub stock: Option<String>, +} + #[derive(Debug, Clone, Subcommand)] pub enum SellCommand { #[command(about = "Create a listing draft")] - Add(ListingNewArgs), + Add(SellAddArgs), #[command(about = "Show a local listing draft")] Show(SellShowArgs), #[command(about = "Check a listing draft")] @@ -1154,7 +1173,7 @@ pub enum SellCommand { #[derive(Debug, Clone, Args)] pub struct SellShowArgs { - pub target: String, + pub file: PathBuf, } #[derive(Debug, Clone, Args)] @@ -1377,10 +1396,26 @@ mod tests { _ => panic!("unexpected command variant"), } - let sell = CliArgs::parse_from(["radroots", "sell", "check", "draft.toml"]); + let sell = CliArgs::parse_from([ + "radroots", + "sell", + "add", + "eggs", + "--pack", + "dozen", + "--price", + "8 USD/dozen", + "--stock", + "10", + ]); match sell.command { Command::Sell(args) => match args.command { - SellCommand::Check(file) => assert_eq!(file.file.to_str(), Some("draft.toml")), + SellCommand::Add(add) => { + assert_eq!(add.product, "eggs"); + assert_eq!(add.pack.as_deref(), Some("dozen")); + assert_eq!(add.price_expr.as_deref(), Some("8 USD/dozen")); + assert_eq!(add.stock.as_deref(), Some("10")); + } _ => panic!("unexpected sell subcommand"), }, _ => panic!("unexpected command variant"), @@ -2154,7 +2189,7 @@ mod tests { .supports_output_format(OutputFormat::Ndjson) ); - let sell_add = CliArgs::parse_from(["radroots", "sell", "add"]); + let sell_add = CliArgs::parse_from(["radroots", "sell", "add", "tomatoes"]); assert_eq!(sell_add.command.display_name(), "sell add"); assert!(!sell_add.command.supports_dry_run()); diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod order; pub mod relay; pub mod rpc; pub mod runtime; +pub mod sell; pub mod signer; pub mod sync; pub mod workflow; @@ -107,20 +108,14 @@ pub fn dispatch( RpcCommand::Sessions => Ok(rpc::sessions(config)), }, Command::Sell(sell) => match &sell.command { - SellCommand::Add(args) => listing::new(config, args), - SellCommand::Show(_args) => planned_command( - "`sell show` will inspect local drafts in the next slice; use `listing validate <file>` for now", - ), - SellCommand::Check(args) => listing::validate(config, args), - SellCommand::Publish(args) => listing::publish(config, args), - SellCommand::Update(args) => listing::update(config, args), - SellCommand::Pause(args) => listing::archive(config, args), - SellCommand::Reprice(_args) => planned_command( - "`sell reprice` will land in the draft-mutation slice; edit the draft file directly for now", - ), - SellCommand::Restock(_args) => planned_command( - "`sell restock` will land in the draft-mutation slice; edit the draft file directly for now", - ), + SellCommand::Add(args) => sell::add(config, args), + SellCommand::Show(args) => sell::show(config, args), + SellCommand::Check(args) => sell::check(config, args), + SellCommand::Publish(args) => sell::publish(config, args), + SellCommand::Update(args) => sell::update(config, args), + SellCommand::Pause(args) => sell::pause(config, args), + SellCommand::Reprice(args) => sell::reprice(config, args), + SellCommand::Restock(args) => sell::restock(config, args), }, Command::Setup(setup) => workflow::setup(config, setup), Command::Runtime(runtime_command) => match &runtime_command.command { @@ -145,7 +140,3 @@ pub fn dispatch( }, } } - -fn planned_command(message: &str) -> Result<CommandOutput, RuntimeError> { - Err(RuntimeError::Config(message.to_owned())) -} diff --git a/src/commands/sell.rs b/src/commands/sell.rs @@ -0,0 +1,150 @@ +use crate::cli::{ + ListingFileArgs, ListingMutationArgs, SellAddArgs, SellRepriceArgs, SellRestockArgs, + SellShowArgs, +}; +use crate::domain::runtime::{ + CommandDisposition, CommandOutput, CommandView, SellAddView, SellCheckView, + SellDraftMutationView, SellMutationView, SellShowView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub fn add(config: &RuntimeConfig, args: &SellAddArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_add(config, args)?; + Ok(sell_add_output(view)) +} + +pub fn show(config: &RuntimeConfig, args: &SellShowArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_show(config, args)?; + Ok(sell_show_output(view)) +} + +pub fn check( + config: &RuntimeConfig, + args: &ListingFileArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_check(config, args)?; + Ok(sell_check_output(view)) +} + +pub fn publish( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_publish(config, args)?; + Ok(sell_mutation_output(view)) +} + +pub fn update( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_update(config, args)?; + Ok(sell_mutation_output(view)) +} + +pub fn pause( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_pause(config, args)?; + Ok(sell_mutation_output(view)) +} + +pub fn reprice( + config: &RuntimeConfig, + args: &SellRepriceArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_reprice(config, args)?; + Ok(sell_draft_mutation_output(view)) +} + +pub fn restock( + config: &RuntimeConfig, + args: &SellRestockArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::listing::sell_restock(config, args)?; + Ok(sell_draft_mutation_output(view)) +} + +fn sell_add_output(view: SellAddView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SellAdd(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::SellAdd(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SellAdd(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::SellAdd(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SellAdd(view)) + } + } +} + +fn sell_show_output(view: SellShowView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SellShow(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SellShow(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SellShow(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::SellShow(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SellShow(view)) + } + } +} + +fn sell_check_output(view: SellCheckView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SellCheck(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SellCheck(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SellCheck(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::SellCheck(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SellCheck(view)) + } + } +} + +fn sell_mutation_output(view: SellMutationView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SellMutation(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SellMutation(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SellMutation(view)) + } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::SellMutation(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SellMutation(view)) + } + } +} + +fn sell_draft_mutation_output(view: SellDraftMutationView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::SellDraftMutation(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::SellDraftMutation(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::SellDraftMutation(view)) + } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::SellDraftMutation(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::SellDraftMutation(view)) + } + } +} diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -122,6 +122,11 @@ pub enum CommandView { RuntimeConfigShow(RuntimeManagedConfigView), RuntimeLogs(RuntimeLogsView), RuntimeStatus(RuntimeStatusView), + SellAdd(SellAddView), + SellCheck(SellCheckView), + SellDraftMutation(SellDraftMutationView), + SellMutation(SellMutationView), + SellShow(SellShowView), Setup(SetupView), SignerStatus(SignerStatusView), Status(StatusView), @@ -1468,6 +1473,164 @@ pub struct ListingValidationIssueView { } #[derive(Debug, Clone, Serialize)] +pub struct SellAddView { + pub state: String, + pub source: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub offer: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stock: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery_method: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_primary: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl SellAddView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SellShowView { + pub state: String, + pub source: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub offer: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stock: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery_method: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_primary: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl SellShowView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SellCheckView { + pub state: String, + pub source: String, + pub file: String, + pub valid: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_ref: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<ListingValidationIssueView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl SellCheckView { + pub fn disposition(&self) -> CommandDisposition { + CommandDisposition::Success + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SellMutationView { + pub state: String, + pub operation: String, + pub source: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + pub listing_addr: String, + #[serde(default)] + pub dry_run: bool, + #[serde(default)] + pub deduplicated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub publish_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_status: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl SellMutationView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "unavailable" => CommandDisposition::ExternalUnavailable, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SellDraftMutationView { + pub state: String, + pub operation: String, + pub source: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + pub changed_label: String, + pub changed_value: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl SellDraftMutationView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct ListingGetView { pub state: String, pub source: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -8,8 +8,9 @@ use crate::domain::runtime::{ LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, - RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SetupView, - StatusView, SyncActionView, SyncStatusView, SyncWatchView, + RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SellAddView, + SellCheckView, SellDraftMutationView, SellMutationView, SellShowView, SetupView, StatusView, + SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -190,6 +191,21 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::RuntimeStatus(view) => { render_runtime_status(stdout, view)?; } + CommandView::SellAdd(view) => { + render_sell_add(stdout, view)?; + } + CommandView::SellCheck(view) => { + render_sell_check(stdout, view)?; + } + CommandView::SellDraftMutation(view) => { + render_sell_draft_mutation(stdout, view)?; + } + CommandView::SellMutation(view) => { + render_sell_mutation(stdout, view)?; + } + CommandView::SellShow(view) => { + render_sell_show(stdout, view)?; + } CommandView::Setup(view) => { render_setup(stdout, view)?; } @@ -421,6 +437,26 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::SellAdd(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::SellCheck(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::SellDraftMutation(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::SellMutation(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::SellShow(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::Setup(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -2111,6 +2147,238 @@ fn render_market_view(stdout: &mut dyn Write, view: &ListingGetView) -> Result<( Ok(()) } +fn render_sell_add(stdout: &mut dyn Write, view: &SellAddView) -> Result<(), RuntimeError> { + writeln!(stdout, "Listing draft saved")?; + writeln!(stdout)?; + writeln!(stdout, "The draft is local until you publish it.")?; + writeln!(stdout)?; + + let mut draft_rows = vec![("File", view.file.clone())]; + push_row(&mut draft_rows, "Listing", view.product_key.clone()); + push_row(&mut draft_rows, "Title", view.title.clone()); + push_row(&mut draft_rows, "Offer", view.offer.clone()); + push_row(&mut draft_rows, "Price", view.price.clone()); + push_row(&mut draft_rows, "Stock", view.stock.clone()); + render_owned_pairs(stdout, "Draft", draft_rows.as_slice())?; + + let mut default_rows = Vec::<(&str, String)>::new(); + push_row(&mut default_rows, "Farm", view.farm_name.clone()); + push_row( + &mut default_rows, + "Delivery", + view.delivery_method + .as_deref() + .map(humanize_delivery_method), + ); + push_row(&mut default_rows, "Place", view.location_primary.clone()); + if !default_rows.is_empty() { + render_owned_pairs(stdout, "Defaults", default_rows.as_slice())?; + } + + if let Some(reason) = &view.reason { + writeln!(stdout, "{reason}")?; + writeln!(stdout)?; + } + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + +fn render_sell_show(stdout: &mut dyn Write, view: &SellShowView) -> Result<(), RuntimeError> { + writeln!(stdout, "Listing draft")?; + writeln!(stdout)?; + + let mut draft_rows = vec![("File", view.file.clone())]; + push_row(&mut draft_rows, "Listing", view.product_key.clone()); + push_row(&mut draft_rows, "Title", view.title.clone()); + push_row(&mut draft_rows, "Category", view.category.clone()); + push_row(&mut draft_rows, "Offer", view.offer.clone()); + push_row(&mut draft_rows, "Price", view.price.clone()); + push_row(&mut draft_rows, "Stock", view.stock.clone()); + push_row( + &mut draft_rows, + "Delivery", + view.delivery_method + .as_deref() + .map(humanize_delivery_method), + ); + push_row(&mut draft_rows, "Place", view.location_primary.clone()); + render_owned_pairs(stdout, "Draft", draft_rows.as_slice())?; + + if let Some(reason) = &view.reason { + writeln!(stdout, "{reason}")?; + writeln!(stdout)?; + } + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + +fn render_sell_check(stdout: &mut dyn Write, view: &SellCheckView) -> Result<(), RuntimeError> { + if view.valid { + writeln!(stdout, "Draft looks ready")?; + writeln!(stdout)?; + let mut draft_rows = vec![("File", view.file.clone())]; + push_row(&mut draft_rows, "Listing", view.product_key.clone()); + push_row(&mut draft_rows, "Seller", view.seller_pubkey.clone()); + push_row(&mut draft_rows, "Farm", view.farm_ref.clone()); + render_owned_pairs(stdout, "Draft", draft_rows.as_slice())?; + } else { + writeln!(stdout, "Draft needs changes")?; + writeln!(stdout)?; + let rows = view + .issues + .iter() + .map(|issue| (issue.field.as_str(), issue.message.clone())) + .collect::<Vec<_>>(); + render_field_rows(stdout, rows.as_slice())?; + } + + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + +fn render_sell_mutation( + stdout: &mut dyn Write, + view: &SellMutationView, +) -> Result<(), RuntimeError> { + match view.state.as_str() { + "dry_run" => { + writeln!(stdout, "Dry run only")?; + writeln!(stdout)?; + writeln!( + stdout, + "Listing would be {}.", + match view.operation.as_str() { + "publish" => "published", + "update" => "updated", + "pause" => "paused", + _ => "changed", + } + )?; + writeln!(stdout)?; + let mut rows = vec![("File", view.file.clone())]; + push_row(&mut rows, "Listing", view.product_key.clone()); + rows.push(("Address", view.listing_addr.clone())); + if view.operation == "publish" { + push_row( + &mut rows, + "Publish mode", + view.publish_mode.as_deref().map(|mode| { + if mode == "runtime_bridge" { + "Runtime bridge".to_owned() + } else { + mode.to_owned() + } + }), + ); + } + render_owned_pairs(stdout, "Listing", rows.as_slice())?; + writeln!(stdout, "Nothing was written.")?; + } + "unconfigured" => { + writeln!(stdout, "Not ready yet")?; + if let Some(reason) = &view.reason { + writeln!(stdout)?; + writeln!(stdout, "{reason}")?; + } + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + } + "unavailable" => { + writeln!(stdout, "Unavailable right now")?; + if let Some(reason) = &view.reason { + writeln!(stdout)?; + writeln!(stdout, "{reason}")?; + } + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + } + "error" => { + writeln!(stdout, "Something went wrong")?; + if let Some(reason) = &view.reason { + writeln!(stdout)?; + writeln!(stdout, "{reason}")?; + } + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + } + _ => { + writeln!( + stdout, + "{}", + match view.operation.as_str() { + "publish" => "Listing published", + "update" => "Listing updated", + "pause" => "Listing paused", + _ => "Listing updated", + } + )?; + writeln!(stdout)?; + let mut listing_rows = vec![("File", view.file.clone())]; + push_row(&mut listing_rows, "Listing", view.product_key.clone()); + listing_rows.push(("Address", view.listing_addr.clone())); + if view.operation == "publish" { + push_row( + &mut listing_rows, + "Publish mode", + view.publish_mode.as_deref().map(|mode| { + if mode == "runtime_bridge" { + "Runtime bridge".to_owned() + } else { + mode.to_owned() + } + }), + ); + } + render_owned_pairs(stdout, "Listing", listing_rows.as_slice())?; + + let mut job_rows = Vec::<(&str, String)>::new(); + push_row(&mut job_rows, "State", view.job_status.clone()); + push_row(&mut job_rows, "Job", view.job_id.clone()); + push_row(&mut job_rows, "Event", view.event_id.clone()); + if !job_rows.is_empty() { + render_owned_pairs(stdout, "Job", job_rows.as_slice())?; + } + + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + } + } + Ok(()) +} + +fn render_sell_draft_mutation( + stdout: &mut dyn Write, + view: &SellDraftMutationView, +) -> Result<(), RuntimeError> { + writeln!(stdout, "Draft updated")?; + writeln!(stdout)?; + render_owned_pairs( + stdout, + "Changed", + &[(view.changed_label.as_str(), view.changed_value.clone())], + )?; + let mut draft_rows = vec![("File", view.file.clone())]; + push_row(&mut draft_rows, "Listing", view.product_key.clone()); + render_owned_pairs(stdout, "Draft", draft_rows.as_slice())?; + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) +} + fn render_listing_mutation( stdout: &mut dyn Write, view: &ListingMutationView, @@ -3373,6 +3641,20 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::RuntimeConfigShow(_) => "runtime config show", CommandView::RuntimeLogs(_) => "runtime logs", CommandView::RuntimeStatus(_) => "runtime status", + CommandView::SellAdd(_) => "sell add", + CommandView::SellCheck(_) => "sell check", + CommandView::SellDraftMutation(view) => match view.operation.as_str() { + "reprice" => "sell reprice", + "restock" => "sell restock", + _ => "sell", + }, + CommandView::SellMutation(view) => match view.operation.as_str() { + "publish" => "sell publish", + "update" => "sell update", + "pause" => "sell pause", + _ => "sell", + }, + CommandView::SellShow(_) => "sell show", CommandView::Setup(_) => "setup", CommandView::SignerStatus(_) => "signer status", CommandView::Status(_) => "status", diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -24,11 +24,15 @@ use radroots_trade::listing::publish::validate_listing_for_seller; use radroots_trade::listing::validation::validate_listing_event; use serde::{Deserialize, Serialize}; -use crate::cli::{ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs}; +use crate::cli::{ + ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs, SellAddArgs, + SellRepriceArgs, SellRestockArgs, SellShowArgs, +}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, - ListingValidateView, ListingValidationIssueView, SyncFreshnessView, + ListingValidateView, ListingValidationIssueView, SellAddView, SellCheckView, + SellDraftMutationView, SellMutationView, SellShowView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -142,6 +146,7 @@ struct ListingAuthoringDefaults { farm_defaults_ready: bool, farm_next_action: Option<String>, farm_reason: Option<String>, + farm_name: Option<String>, selected_account_id: Option<String>, selected_account_pubkey: Option<String>, selected_farm_d_tag: Option<String>, @@ -150,6 +155,33 @@ struct ListingAuthoringDefaults { } #[derive(Debug, Clone)] +struct DraftSummary { + product_key: Option<String>, + title: Option<String>, + category: Option<String>, + offer: Option<String>, + price: Option<String>, + stock: Option<String>, + delivery_method: Option<String>, + location_primary: Option<String>, +} + +#[derive(Debug, Clone)] +struct ParsedQuantityExpr { + amount: String, + unit: String, + label: String, +} + +#[derive(Debug, Clone)] +struct ParsedPriceExpr { + amount: String, + currency: String, + per_amount: String, + per_unit: String, +} + +#[derive(Debug, Clone)] struct CanonicalListingDraft { listing_id: String, seller_pubkey: String, @@ -178,9 +210,235 @@ pub fn scaffold( config: &RuntimeConfig, args: &ListingNewArgs, ) -> Result<ListingNewView, RuntimeError> { + let (draft, defaults) = build_listing_draft(config, args)?; + let output_path = default_listing_output_path(args.output.as_ref(), &draft.listing.d_tag)?; + write_listing_draft(&output_path, &draft, false)?; + + let mut actions = vec![format!( + "radroots listing validate {}", + output_path.display() + )]; + if defaults.selected_account_pubkey.is_none() { + actions.push("radroots account new".to_owned()); + } + if let Some(action) = &defaults.farm_next_action { + actions.push(action.clone()); + } + + Ok(ListingNewView { + state: "draft created".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: output_path.display().to_string(), + listing_id: draft.listing.d_tag, + selected_account_id: defaults.selected_account_id, + seller_pubkey: defaults.selected_account_pubkey, + farm_d_tag: defaults.selected_farm_d_tag, + delivery_method: non_empty(draft.delivery.method.clone()), + location_primary: non_empty(draft.location.primary.clone()), + reason: defaults.farm_reason, + actions, + }) +} + +pub fn sell_add(config: &RuntimeConfig, args: &SellAddArgs) -> Result<SellAddView, RuntimeError> { + let listing_args = listing_args_from_sell_add(args)?; + let (draft, defaults) = build_listing_draft(config, &listing_args)?; + let output_path = listing_args + .output + .clone() + .expect("sell add always sets an explicit output path"); + write_listing_draft(&output_path, &draft, false)?; + + let summary = summarize_draft(&draft); + let mut actions = vec![format!("radroots sell check {}", output_path.display())]; + if defaults.selected_account_pubkey.is_some() && defaults.selected_farm_d_tag.is_some() { + actions.push(format!("radroots sell publish {}", output_path.display())); + } + if defaults.selected_account_pubkey.is_none() { + actions.push("radroots account create".to_owned()); + } + if let Some(action) = &defaults.farm_next_action { + actions.push(action.clone()); + } + + Ok(SellAddView { + state: "draft_saved".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: output_path.display().to_string(), + product_key: summary.product_key, + title: summary.title, + offer: summary.offer, + price: summary.price, + stock: summary.stock, + farm_name: defaults.farm_name, + delivery_method: summary.delivery_method, + location_primary: summary.location_primary, + reason: defaults.farm_reason, + actions, + }) +} + +pub fn sell_show( + _config: &RuntimeConfig, + args: &SellShowArgs, +) -> Result<SellShowView, RuntimeError> { + let draft = read_listing_draft(&args.file)?; + let summary = summarize_draft(&draft); + Ok(SellShowView { + state: "ready".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: args.file.display().to_string(), + product_key: summary.product_key, + title: summary.title, + category: summary.category, + offer: summary.offer, + price: summary.price, + stock: summary.stock, + delivery_method: summary.delivery_method, + location_primary: summary.location_primary, + reason: None, + actions: vec![ + format!("radroots sell check {}", args.file.display()), + format!("radroots sell publish {}", args.file.display()), + ], + }) +} + +pub fn sell_reprice( + _config: &RuntimeConfig, + args: &SellRepriceArgs, +) -> Result<SellDraftMutationView, RuntimeError> { + let mut draft = read_listing_draft(&args.file)?; + let parsed = parse_price_expr(args.price_expr.as_str())?; + draft.primary_bin.price_amount = parsed.amount; + draft.primary_bin.price_currency = parsed.currency; + draft.primary_bin.price_per_amount = parsed.per_amount; + draft.primary_bin.price_per_unit = parsed.per_unit; + write_listing_draft(&args.file, &draft, true)?; + + let summary = summarize_draft(&draft); + Ok(SellDraftMutationView { + state: "updated".to_owned(), + operation: "reprice".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: args.file.display().to_string(), + product_key: summary.product_key, + changed_label: "Price".to_owned(), + changed_value: summary + .price + .unwrap_or_else(|| args.price_expr.trim().to_owned()), + actions: vec![ + format!("radroots sell check {}", args.file.display()), + format!("radroots sell update {}", args.file.display()), + ], + }) +} + +pub fn sell_restock( + _config: &RuntimeConfig, + args: &SellRestockArgs, +) -> Result<SellDraftMutationView, RuntimeError> { + let mut draft = read_listing_draft(&args.file)?; + parse_decimal_string(args.available.as_str(), "`sell restock <available>`")?; + draft.inventory.available = args.available.trim().to_owned(); + write_listing_draft(&args.file, &draft, true)?; + + let summary = summarize_draft(&draft); + Ok(SellDraftMutationView { + state: "updated".to_owned(), + operation: "restock".to_owned(), + source: LISTING_SOURCE.to_owned(), + file: args.file.display().to_string(), + product_key: summary.product_key, + changed_label: "Stock".to_owned(), + changed_value: summary + .stock + .unwrap_or_else(|| format!("{} available", args.available.trim())), + actions: vec![ + format!("radroots sell check {}", args.file.display()), + format!("radroots sell update {}", args.file.display()), + ], + }) +} + +pub fn sell_check( + config: &RuntimeConfig, + args: &ListingFileArgs, +) -> Result<SellCheckView, RuntimeError> { + let view = validate(config, args)?; + let summary = read_listing_draft(&args.file) + .ok() + .map(|draft| summarize_draft(&draft)); + let actions = if view.valid { + vec![format!("radroots sell publish {}", args.file.display())] + } else { + vec![ + format!("radroots sell show {}", args.file.display()), + "Edit the draft file and run the command again".to_owned(), + ] + }; + + Ok(SellCheckView { + state: if view.valid { + "ready".to_owned() + } else { + "invalid".to_owned() + }, + source: view.source, + file: view.file, + valid: view.valid, + product_key: summary + .as_ref() + .and_then(|summary| summary.product_key.clone()), + seller_pubkey: view.seller_pubkey, + farm_ref: view.farm_d_tag, + issues: view.issues, + actions, + }) +} + +pub fn sell_publish( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<SellMutationView, RuntimeError> { + let view = publish(config, args)?; + Ok(sell_mutation_from_listing( + view, + args.file.as_path(), + "publish", + )) +} + +pub fn sell_update( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<SellMutationView, RuntimeError> { + let view = update(config, args)?; + Ok(sell_mutation_from_listing( + view, + args.file.as_path(), + "update", + )) +} + +pub fn sell_pause( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Result<SellMutationView, RuntimeError> { + let view = archive(config, args)?; + Ok(sell_mutation_from_listing( + view, + args.file.as_path(), + "pause", + )) +} + +fn build_listing_draft( + config: &RuntimeConfig, + args: &ListingNewArgs, +) -> Result<(ListingDraftDocument, ListingAuthoringDefaults), RuntimeError> { let defaults = authoring_defaults(config)?; let quantity_unit = args.quantity_unit.clone().unwrap_or_else(|| "g".to_owned()); - let draft = ListingDraftDocument { version: 1, kind: DRAFT_KIND.to_owned(), @@ -239,12 +497,71 @@ pub fn scaffold( country: None, }), }; + Ok((draft, defaults)) +} + +fn listing_args_from_sell_add(args: &SellAddArgs) -> Result<ListingNewArgs, RuntimeError> { + let product_key = slugify_ascii(args.product.as_str()); + if product_key.is_empty() { + return Err(RuntimeError::Config( + "`sell add <product>` requires at least one ASCII letter or digit".to_owned(), + )); + } - let output_path = match &args.output { + let title = args + .title + .clone() + .unwrap_or_else(|| title_case_ascii(args.product.as_str())); + let category = args.category.clone().unwrap_or_else(|| title.clone()); + let pack = args.pack.as_deref().map(parse_quantity_expr).transpose()?; + let price = args + .price_expr + .as_deref() + .map(parse_price_expr) + .transpose()?; + let output = Some(match &args.file { Some(path) => path.clone(), - None => std::env::current_dir()?.join(format!("listing-{}.toml", draft.listing.d_tag)), - }; - if output_path.exists() { + None => std::path::PathBuf::from(format!("listing-{product_key}.toml")), + }); + + Ok(ListingNewArgs { + output, + key: Some(product_key), + title: Some(title.clone()), + category: Some(category), + summary: Some( + args.summary + .clone() + .unwrap_or_else(|| format!("Listing for {title}")), + ), + bin_id: None, + quantity_amount: pack.as_ref().map(|pack| pack.amount.clone()), + quantity_unit: pack.as_ref().map(|pack| pack.unit.clone()), + price_amount: price.as_ref().map(|price| price.amount.clone()), + price_currency: price.as_ref().map(|price| price.currency.clone()), + price_per_amount: price.as_ref().map(|price| price.per_amount.clone()), + price_per_unit: price.as_ref().map(|price| price.per_unit.clone()), + available: args.stock.clone(), + label: pack.as_ref().map(|pack| pack.label.clone()), + }) +} + +fn default_listing_output_path( + explicit: Option<&std::path::PathBuf>, + listing_id: &str, +) -> Result<std::path::PathBuf, RuntimeError> { + match explicit { + Some(path) => Ok(path.clone()), + None => Ok(std::env::current_dir()?.join(format!("listing-{listing_id}.toml"))), + } +} + +fn write_listing_draft( + output_path: &Path, + draft: &ListingDraftDocument, + overwrite: bool, +) -> Result<(), RuntimeError> { + if output_path.exists() && !overwrite { return Err(RuntimeError::Config(format!( "listing draft output {} already exists", output_path.display() @@ -253,34 +570,286 @@ pub fn scaffold( if let Some(parent) = output_path.parent() { fs::create_dir_all(parent)?; } - fs::write(&output_path, scaffold_contents(&draft)?)?; + fs::write(output_path, scaffold_contents(draft)?)?; + Ok(()) +} - let mut actions = vec![format!( - "radroots listing validate {}", - output_path.display() - )]; - if defaults.selected_account_pubkey.is_none() { - actions.push("radroots account new".to_owned()); - } - if let Some(action) = &defaults.farm_next_action { - actions.push(action.clone()); +fn read_listing_draft(path: &Path) -> Result<ListingDraftDocument, RuntimeError> { + let contents = fs::read_to_string(path)?; + toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { + RuntimeError::Config(format!( + "failed to parse listing draft {}: {error}", + path.display() + )) + }) +} + +fn translate_sell_actions(actions: &[String]) -> Vec<String> { + actions + .iter() + .map(|action| { + action + .replace("radroots listing validate ", "radroots sell check ") + .replace("radroots listing publish ", "radroots sell publish ") + .replace("radroots listing update ", "radroots sell update ") + .replace("radroots listing archive ", "radroots sell pause ") + .replace("radroots account new", "radroots account create") + }) + .collect() +} + +fn successful_sell_mutation_actions(operation: &str, product_key: Option<&str>) -> Vec<String> { + match operation { + "publish" => { + let mut actions = Vec::new(); + if let Some(product_key) = product_key { + actions.push(format!("radroots market view {product_key}")); + actions.push(format!("radroots sell add {product_key}")); + } + actions + } + "update" => product_key + .map(|product_key| vec![format!("radroots market view {product_key}")]) + .unwrap_or_default(), + "pause" => product_key + .map(|product_key| vec![format!("radroots sell add {product_key}")]) + .unwrap_or_default(), + _ => Vec::new(), } +} - Ok(ListingNewView { - state: "draft created".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: output_path.display().to_string(), - listing_id: draft.listing.d_tag, - selected_account_id: defaults.selected_account_id, - seller_pubkey: defaults.selected_account_pubkey, - farm_d_tag: defaults.selected_farm_d_tag, +fn summarize_draft(draft: &ListingDraftDocument) -> DraftSummary { + DraftSummary { + product_key: non_empty(draft.product.key.clone()), + title: non_empty(draft.product.title.clone()), + category: non_empty(draft.product.category.clone()), + offer: draft_offer_text(draft), + price: draft_price_text(draft), + stock: draft_stock_text(draft), delivery_method: non_empty(draft.delivery.method.clone()), location_primary: non_empty(draft.location.primary.clone()), - reason: defaults.farm_reason, + } +} + +fn sell_mutation_from_listing( + view: ListingMutationView, + file: &Path, + operation: &str, +) -> SellMutationView { + let summary = read_listing_draft(file) + .ok() + .map(|draft| summarize_draft(&draft)); + let product_key = summary + .as_ref() + .and_then(|summary| summary.product_key.clone()); + let actions = match view.state.as_str() { + "published" | "deduplicated" => { + successful_sell_mutation_actions(operation, product_key.as_deref()) + } + _ => translate_sell_actions(view.actions.as_slice()), + }; + + SellMutationView { + state: view.state, + operation: operation.to_owned(), + source: view.source, + file: view.file, + product_key, + listing_addr: view.listing_addr, + dry_run: view.dry_run, + deduplicated: view.deduplicated, + publish_mode: Some("runtime_bridge".to_owned()), + job_id: view.job_id, + job_status: view.job_status, + event_id: view.event_id, + reason: view.reason, actions, + } +} + +fn draft_offer_text(draft: &ListingDraftDocument) -> Option<String> { + non_empty(draft.primary_bin.label.clone()).or_else(|| { + let amount = draft.primary_bin.quantity_amount.trim(); + let unit = draft.primary_bin.quantity_unit.trim(); + if amount.is_empty() || unit.is_empty() { + None + } else { + Some(format!("{} {}", trim_decimal_string(amount), unit)) + } + }) +} + +fn draft_price_text(draft: &ListingDraftDocument) -> Option<String> { + let amount = non_empty(draft.primary_bin.price_amount.clone())?; + let currency = non_empty(draft.primary_bin.price_currency.clone())?; + let per_amount = non_empty(draft.primary_bin.price_per_amount.clone())?; + let per_unit = non_empty(draft.primary_bin.price_per_unit.clone())?; + let denominator = if per_unit == "each" + && numeric_strings_equal( + per_amount.as_str(), + draft.primary_bin.quantity_amount.trim(), + ) + && !draft.primary_bin.label.trim().is_empty() + { + draft.primary_bin.label.trim().to_owned() + } else if per_amount == "1" { + per_unit.to_owned() + } else { + format!("{} {}", trim_decimal_string(&per_amount), per_unit) + }; + Some(format!( + "{} {}/{}", + trim_decimal_string(&amount), + currency.to_ascii_uppercase(), + denominator + )) +} + +fn draft_stock_text(draft: &ListingDraftDocument) -> Option<String> { + non_empty(draft.inventory.available.clone()) + .map(|available| format!("{} available", trim_decimal_string(&available))) +} + +fn parse_quantity_expr(expr: &str) -> Result<ParsedQuantityExpr, RuntimeError> { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Err(RuntimeError::Config( + "quantity expression must not be empty".to_owned(), + )); + } + if trimmed.eq_ignore_ascii_case("dozen") { + return Ok(ParsedQuantityExpr { + amount: "12".to_owned(), + unit: "each".to_owned(), + label: "dozen".to_owned(), + }); + } + + let parts = trimmed.split_whitespace().collect::<Vec<_>>(); + if parts.is_empty() { + return Err(RuntimeError::Config( + "quantity expression must not be empty".to_owned(), + )); + } + + let (amount, unit) = if parse_decimal_string(parts[0], "quantity amount").is_ok() { + let Some(unit) = parts.get(1) else { + return Err(RuntimeError::Config( + "quantity expression must include a unit, for example `1 kg`".to_owned(), + )); + }; + (parts[0].trim().to_owned(), unit.trim().to_ascii_lowercase()) + } else { + ("1".to_owned(), parts[0].trim().to_ascii_lowercase()) + }; + + unit.parse::<RadrootsCoreUnit>().map_err(|_| { + RuntimeError::Config(format!( + "quantity expression uses unsupported unit `{unit}`" + )) + })?; + + Ok(ParsedQuantityExpr { + amount, + unit, + label: trimmed.to_owned(), }) } +fn parse_price_expr(expr: &str) -> Result<ParsedPriceExpr, RuntimeError> { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return Err(RuntimeError::Config( + "price expression must not be empty".to_owned(), + )); + } + + let segments = trimmed.split_whitespace().collect::<Vec<_>>(); + if segments.len() < 2 { + return Err(RuntimeError::Config( + "price expression must look like `10 USD/kg`".to_owned(), + )); + } + + parse_decimal_string(segments[0], "price amount")?; + let remainder = segments[1..].join(" "); + let Some((currency, per_expr)) = remainder.split_once('/') else { + return Err(RuntimeError::Config( + "price expression must include a `/`, for example `10 USD/kg`".to_owned(), + )); + }; + let per = parse_quantity_expr(per_expr)?; + RadrootsCoreCurrency::from_str_upper(currency.trim().to_ascii_uppercase().as_str()).map_err( + |_| { + RuntimeError::Config(format!( + "price expression uses unsupported currency `{}`", + currency.trim() + )) + }, + )?; + + Ok(ParsedPriceExpr { + amount: segments[0].trim().to_owned(), + currency: currency.trim().to_ascii_uppercase(), + per_amount: per.amount, + per_unit: per.unit, + }) +} + +fn parse_decimal_string(value: &str, label: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + value + .trim() + .parse::<RadrootsCoreDecimal>() + .map_err(|_| RuntimeError::Config(format!("{label} must be a valid decimal value"))) +} + +fn slugify_ascii(value: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if !slug.is_empty() && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + slug.trim_matches('-').to_owned() +} + +fn title_case_ascii(value: &str) -> String { + value + .split(|ch: char| !ch.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(capitalize_ascii_word) + .collect::<Vec<_>>() + .join(" ") +} + +fn capitalize_ascii_word(word: &str) -> String { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut rendered = String::new(); + rendered.push(first.to_ascii_uppercase()); + rendered.push_str(chars.as_str()); + rendered +} + +fn numeric_strings_equal(lhs: &str, rhs: &str) -> bool { + trim_decimal_string(lhs) == trim_decimal_string(rhs) +} + +fn trim_decimal_string(value: &str) -> String { + if let Ok(parsed) = value.trim().parse::<RadrootsCoreDecimal>() { + parsed.to_string() + } else { + value.trim().to_owned() + } +} + pub fn validate( config: &RuntimeConfig, args: &ListingFileArgs, @@ -1237,6 +1806,7 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults "selected farm draft not found; delivery, location, and farm defaults were left blank" .to_owned(), ), + farm_name: None, selected_account_id: selected_account .as_ref() .map(|account| account.record.account_id.to_string()), @@ -1259,6 +1829,14 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults }; defaults.farm_config_present = true; + defaults.farm_name = resolved + .document + .profile + .display_name + .clone() + .and_then(non_empty) + .or_else(|| non_empty(resolved.document.profile.name.clone())) + .or_else(|| non_empty(resolved.document.farm.name.clone())); defaults.selected_account_id = Some(resolved.document.selection.account.clone()); defaults.selected_account_pubkey = Some(account.record.public_identity.public_key_hex.clone()); defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); diff --git a/tests/sell.rs b/tests/sell.rs @@ -0,0 +1,413 @@ +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn cli_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", + "RADROOTS_HYF_ENABLED", + "RADROOTS_HYF_EXECUTABLE", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_RPC_URL", + "RADROOTS_RPC_BEARER_TOKEN", + ] { + command.env_remove(key); + } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); + command +} + +fn sell_test_guard() -> MutexGuard<'static, ()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn bootstrap_seller(workdir: &Path) { + let account_output = cli_command_in(workdir) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + + let farm_output = cli_command_in(workdir) + .args([ + "--json", + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--city", + "San Francisco", + "--region", + "CA", + "--country", + "US", + "--delivery-method", + "pickup", + ]) + .output() + .expect("run farm setup"); + assert!(farm_output.status.success()); +} + +#[test] +fn sell_add_creates_named_local_draft_from_human_flags() { + let _guard = sell_test_guard(); + let dir = tempdir().expect("tempdir"); + bootstrap_seller(dir.path()); + + let output = cli_command_in(dir.path()) + .args([ + "--json", + "sell", + "add", + "tomatoes", + "--pack", + "1 kg", + "--price", + "10 USD/kg", + "--stock", + "25", + ]) + .output() + .expect("run sell add"); + assert!(output.status.success()); + + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "draft_saved"); + assert_eq!(json["product_key"], "tomatoes"); + assert_eq!(json["title"], "Tomatoes"); + assert_eq!(json["offer"], "1 kg"); + assert_eq!(json["price"], "10 USD/kg"); + assert_eq!(json["stock"], "25 available"); + assert_eq!(json["farm_name"], "La Huerta"); + assert_eq!(json["delivery_method"], "pickup"); + assert_eq!(json["location_primary"], "San Francisco, CA"); + assert_eq!( + json["actions"][0], + "radroots sell check listing-tomatoes.toml" + ); + assert_eq!( + json["actions"][1], + "radroots sell publish listing-tomatoes.toml" + ); + + let draft_path = dir.path().join("listing-tomatoes.toml"); + let contents = fs::read_to_string(&draft_path).expect("draft contents"); + assert!(contents.contains("key = \"tomatoes\"")); + assert!(contents.contains("title = \"Tomatoes\"")); + assert!(contents.contains("category = \"Tomatoes\"")); + assert!(contents.contains("quantity_amount = \"1\"")); + assert!(contents.contains("quantity_unit = \"kg\"")); + assert!(contents.contains("price_amount = \"10\"")); + assert!(contents.contains("price_currency = \"USD\"")); + assert!(contents.contains("price_per_amount = \"1\"")); + assert!(contents.contains("price_per_unit = \"kg\"")); + assert!(contents.contains("available = \"25\"")); + + let human_output = cli_command_in(dir.path()) + .args([ + "sell", "add", "potatoes", "--pack", "2 kg", "--price", "6 USD/kg", "--stock", "10", + ]) + .output() + .expect("run human sell add"); + assert!(human_output.status.success()); + let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Listing draft saved")); + assert!(stdout.contains("The draft is local until you publish it.")); + assert!(stdout.contains("Draft")); + assert!(stdout.contains("Defaults")); + assert!(stdout.contains("radroots sell check listing-potatoes.toml")); +} + +#[test] +fn sell_show_reads_local_draft_only() { + let _guard = sell_test_guard(); + let dir = tempdir().expect("tempdir"); + bootstrap_seller(dir.path()); + + let add = cli_command_in(dir.path()) + .args([ + "sell", + "add", + "tomatoes", + "--pack", + "1 kg", + "--price", + "10 USD/kg", + "--stock", + "25", + ]) + .output() + .expect("run sell add"); + assert!(add.status.success()); + + let output = cli_command_in(dir.path()) + .args(["--json", "sell", "show", "listing-tomatoes.toml"]) + .output() + .expect("run sell show"); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "ready"); + assert_eq!(json["product_key"], "tomatoes"); + assert_eq!(json["title"], "Tomatoes"); + assert_eq!(json["category"], "Tomatoes"); + assert_eq!(json["offer"], "1 kg"); + assert_eq!(json["price"], "10 USD/kg"); + assert_eq!(json["stock"], "25 available"); + assert_eq!(json["delivery_method"], "pickup"); + assert_eq!(json["location_primary"], "San Francisco, CA"); + assert_eq!( + json["actions"][0], + "radroots sell check listing-tomatoes.toml" + ); + + let human_output = cli_command_in(dir.path()) + .args(["sell", "show", "listing-tomatoes.toml"]) + .output() + .expect("run human sell show"); + assert!(human_output.status.success()); + let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Listing draft")); + assert!(stdout.contains("tomatoes")); + assert!(stdout.contains("San Francisco, CA")); + assert!(!stdout.contains("listing ยท")); +} + +#[test] +fn sell_check_reports_ready_and_invalid_drafts() { + let _guard = sell_test_guard(); + let dir = tempdir().expect("tempdir"); + bootstrap_seller(dir.path()); + + let add = cli_command_in(dir.path()) + .args([ + "sell", + "add", + "tomatoes", + "--pack", + "1 kg", + "--price", + "10 USD/kg", + "--stock", + "25", + ]) + .output() + .expect("run sell add"); + assert!(add.status.success()); + + let ready_output = cli_command_in(dir.path()) + .args(["--json", "sell", "check", "listing-tomatoes.toml"]) + .output() + .expect("run ready sell check"); + assert!(ready_output.status.success()); + let ready_json: Value = + serde_json::from_slice(ready_output.stdout.as_slice()).expect("ready json"); + assert_eq!(ready_json["state"], "ready"); + assert_eq!(ready_json["valid"], true); + assert_eq!(ready_json["product_key"], "tomatoes"); + assert_eq!( + ready_json["actions"][0], + "radroots sell publish listing-tomatoes.toml" + ); + + let draft_path = dir.path().join("listing-tomatoes.toml"); + let broken = fs::read_to_string(&draft_path) + .expect("draft contents") + .replace("price_amount = \"10\"", "price_amount = \"\""); + fs::write(&draft_path, broken).expect("write broken draft"); + + let invalid_output = cli_command_in(dir.path()) + .args(["sell", "check", "listing-tomatoes.toml"]) + .output() + .expect("run invalid sell check"); + assert!(invalid_output.status.success()); + let stdout = String::from_utf8(invalid_output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Draft needs changes")); + assert!(stdout.contains("primary_bin.price_amount")); + assert!(stdout.contains("radroots sell show listing-tomatoes.toml")); + assert!(stdout.contains("Edit the draft file and run the command again")); +} + +#[test] +fn sell_reprice_and_restock_mutate_draft_file() { + let _guard = sell_test_guard(); + let dir = tempdir().expect("tempdir"); + bootstrap_seller(dir.path()); + + let add = cli_command_in(dir.path()) + .args([ + "sell", + "add", + "tomatoes", + "--pack", + "1 kg", + "--price", + "10 USD/kg", + "--stock", + "25", + ]) + .output() + .expect("run sell add"); + assert!(add.status.success()); + + let reprice_output = cli_command_in(dir.path()) + .args([ + "--json", + "sell", + "reprice", + "listing-tomatoes.toml", + "12 USD/kg", + ]) + .output() + .expect("run sell reprice"); + assert!(reprice_output.status.success()); + let reprice_json: Value = + serde_json::from_slice(reprice_output.stdout.as_slice()).expect("reprice json"); + assert_eq!(reprice_json["state"], "updated"); + assert_eq!(reprice_json["operation"], "reprice"); + assert_eq!(reprice_json["changed_label"], "Price"); + assert_eq!(reprice_json["changed_value"], "12 USD/kg"); + + let restock_output = cli_command_in(dir.path()) + .args(["--json", "sell", "restock", "listing-tomatoes.toml", "40"]) + .output() + .expect("run sell restock"); + assert!(restock_output.status.success()); + let restock_json: Value = + serde_json::from_slice(restock_output.stdout.as_slice()).expect("restock json"); + assert_eq!(restock_json["state"], "updated"); + assert_eq!(restock_json["operation"], "restock"); + assert_eq!(restock_json["changed_label"], "Stock"); + assert_eq!(restock_json["changed_value"], "40 available"); + + let contents = fs::read_to_string(dir.path().join("listing-tomatoes.toml")).expect("draft"); + assert!(contents.contains("price_amount = \"12\"")); + assert!(contents.contains("available = \"40\"")); +} + +#[test] +fn sell_publish_update_and_pause_wrap_listing_dry_runs() { + let _guard = sell_test_guard(); + let dir = tempdir().expect("tempdir"); + bootstrap_seller(dir.path()); + + let add = cli_command_in(dir.path()) + .args([ + "sell", + "add", + "tomatoes", + "--pack", + "1 kg", + "--price", + "10 USD/kg", + "--stock", + "25", + ]) + .output() + .expect("run sell add"); + assert!(add.status.success()); + + let publish_output = cli_command_in(dir.path()) + .args([ + "--dry-run", + "--json", + "sell", + "publish", + "listing-tomatoes.toml", + ]) + .output() + .expect("run sell publish dry run"); + assert!(publish_output.status.success()); + let publish_json: Value = + serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); + assert_eq!(publish_json["state"], "dry_run"); + assert_eq!(publish_json["operation"], "publish"); + assert_eq!(publish_json["product_key"], "tomatoes"); + assert_eq!( + publish_json["actions"][0], + "radroots sell publish listing-tomatoes.toml" + ); + + let update_output = cli_command_in(dir.path()) + .args([ + "--dry-run", + "--json", + "sell", + "update", + "listing-tomatoes.toml", + ]) + .output() + .expect("run sell update dry run"); + assert!(update_output.status.success()); + let update_json: Value = + serde_json::from_slice(update_output.stdout.as_slice()).expect("update json"); + assert_eq!(update_json["state"], "dry_run"); + assert_eq!(update_json["operation"], "update"); + assert_eq!(update_json["product_key"], "tomatoes"); + assert_eq!( + update_json["actions"][0], + "radroots sell update listing-tomatoes.toml" + ); + + let pause_output = cli_command_in(dir.path()) + .args([ + "--dry-run", + "--json", + "sell", + "pause", + "listing-tomatoes.toml", + ]) + .output() + .expect("run sell pause dry run"); + assert!(pause_output.status.success()); + let pause_json: Value = + serde_json::from_slice(pause_output.stdout.as_slice()).expect("pause json"); + assert_eq!(pause_json["state"], "dry_run"); + assert_eq!(pause_json["operation"], "pause"); + assert_eq!(pause_json["product_key"], "tomatoes"); + assert_eq!( + pause_json["actions"][0], + "radroots sell pause listing-tomatoes.toml" + ); + + let human_output = cli_command_in(dir.path()) + .args(["--dry-run", "sell", "publish", "listing-tomatoes.toml"]) + .output() + .expect("run human sell publish dry run"); + assert!(human_output.status.success()); + let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Dry run only")); + assert!(stdout.contains("Listing would be published.")); + assert!(stdout.contains("Nothing was written.")); +}