commit 4a6618dc1528de5f09cd55434b9ad6524df6579a
parent 42036881bca6cca83280477ad78ca1aeeed5f516
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 21:36:27 +0000
implement human first sell wrappers
Diffstat:
| M | src/cli.rs | | | 49 | ++++++++++++++++++++++++++++++++++++++++++------- |
| M | src/commands/mod.rs | | | 27 | +++++++++------------------ |
| A | src/commands/sell.rs | | | 150 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/domain/runtime.rs | | | 163 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/render/mod.rs | | | 286 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/runtime/listing.rs | | | 630 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| A | tests/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."));
+}