commit c6e1fa25426319031125f57a2b0e78f9df1b7512
parent fcaa7c3b018252041b8d3e63a16a6e2a0daebe0f
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 05:49:58 +0000
land listing read and authoring surfaces
Diffstat:
10 files changed, 1868 insertions(+), 16 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1232,6 +1232,9 @@ version = "0.1.0"
dependencies = [
"assert_cmd",
"clap",
+ "radroots-core",
+ "radroots-events",
+ "radroots-events-codec",
"radroots-identity",
"radroots-log",
"radroots-nostr-accounts",
@@ -1239,6 +1242,7 @@ dependencies = [
"radroots-replica-db",
"radroots-replica-sync",
"radroots-sql-core",
+ "radroots-trade",
"serde",
"serde_json",
"tempfile",
@@ -1254,6 +1258,7 @@ dependencies = [
"rust_decimal",
"rust_decimal_macros",
"serde",
+ "typeshare",
]
[[package]]
@@ -1262,6 +1267,8 @@ version = "0.1.0-alpha.1"
dependencies = [
"radroots-core",
"serde",
+ "ts-rs",
+ "typeshare",
]
[[package]]
@@ -1408,6 +1415,18 @@ dependencies = [
]
[[package]]
+name = "radroots-trade"
+version = "0.1.0-alpha.1"
+dependencies = [
+ "radroots-core",
+ "radroots-events",
+ "radroots-events-codec",
+ "serde",
+ "serde_json",
+ "ts-rs",
+]
+
+[[package]]
name = "radroots-types"
version = "0.1.0-alpha.1"
dependencies = [
@@ -1756,6 +1775,15 @@ dependencies = [
]
[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2003,12 +2031,56 @@ dependencies = [
]
[[package]]
+name = "ts-rs"
+version = "11.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
+dependencies = [
+ "thiserror 2.0.18",
+ "ts-rs-macros",
+]
+
+[[package]]
+name = "ts-rs-macros"
+version = "11.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "termcolor",
+]
+
+[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
+name = "typeshare"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da1bf9fe204f358ffea7f8f779b53923a20278b3ab8e8d97962c5e1b3a54edb7"
+dependencies = [
+ "chrono",
+ "serde",
+ "serde_json",
+ "typeshare-annotation",
+]
+
+[[package]]
+name = "typeshare-annotation"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "621963e302416b389a1ec177397e9e62de849a78bd8205d428608553def75350"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2229,6 +2301,15 @@ dependencies = [
]
[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -18,6 +18,9 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
[dependencies]
clap = { version = "4.5", features = ["derive"] }
+radroots-core = { path = "../lib/crates/core", features = ["std", "serde"] }
+radroots-events = { path = "../lib/crates/events" }
+radroots-events-codec = { path = "../lib/crates/events-codec", features = ["serde_json"] }
radroots-identity = { path = "../lib/crates/identity" }
radroots-log = { path = "../lib/crates/log" }
radroots-nostr-accounts = { path = "../lib/crates/nostr-accounts" }
@@ -25,6 +28,7 @@ radroots-nostr-signer = { path = "../lib/crates/nostr-signer" }
radroots-replica-db = { path = "../lib/crates/replica-db" }
radroots-replica-sync = { path = "../lib/crates/replica-sync" }
radroots-sql-core = { path = "../lib/crates/sql-core", features = ["native"] }
+radroots-trade = { path = "../lib/crates/trade" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
diff --git a/src/cli.rs b/src/cli.rs
@@ -83,10 +83,10 @@ impl Command {
JobCommand::Watch(_) => "job watch",
},
Self::Listing(listing) => match listing.command {
- ListingCommand::New => "listing new",
- ListingCommand::Validate => "listing validate",
+ ListingCommand::New(_) => "listing new",
+ ListingCommand::Validate(_) => "listing validate",
ListingCommand::Get(_) => "listing get",
- ListingCommand::Publish => "listing publish",
+ ListingCommand::Publish(_) => "listing publish",
ListingCommand::Update(_) => "listing update",
ListingCommand::Archive(_) => "listing archive",
},
@@ -162,8 +162,8 @@ impl Command {
}) | Self::Sync(SyncArgs {
command: SyncCommand::Pull | SyncCommand::Push,
}) | Self::Listing(ListingArgs {
- command: ListingCommand::New
- | ListingCommand::Publish
+ command: ListingCommand::New(_)
+ | ListingCommand::Publish(_)
| ListingCommand::Update(_)
| ListingCommand::Archive(_),
}) | Self::Order(OrderArgs {
@@ -326,14 +326,25 @@ pub struct ListingArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum ListingCommand {
- New,
- Validate,
+ New(ListingNewArgs),
+ Validate(ListingFileArgs),
Get(RecordKeyArgs),
- Publish,
+ Publish(ListingFileArgs),
Update(RecordKeyArgs),
Archive(RecordKeyArgs),
}
+#[derive(Debug, Clone, Args, Default)]
+pub struct ListingNewArgs {
+ #[arg(long)]
+ pub output: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct ListingFileArgs {
+ pub file: PathBuf,
+}
+
#[derive(Debug, Clone, Args)]
pub struct JobArgs {
#[command(subcommand)]
@@ -620,8 +631,41 @@ mod tests {
_ => panic!("unexpected command variant"),
}
- let listing = CliArgs::parse_from(["radroots", "listing", "get", "lst_123"]);
- match listing.command {
+ let listing_new = CliArgs::parse_from(["radroots", "listing", "new"]);
+ match listing_new.command {
+ Command::Listing(args) => match args.command {
+ ListingCommand::New(new) => assert!(new.output.is_none()),
+ _ => panic!("unexpected listing subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let listing_validate =
+ CliArgs::parse_from(["radroots", "listing", "validate", "draft.toml"]);
+ match listing_validate.command {
+ Command::Listing(args) => match args.command {
+ ListingCommand::Validate(file) => {
+ assert_eq!(file.file.to_str(), Some("draft.toml"));
+ }
+ _ => panic!("unexpected listing subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let listing_publish =
+ CliArgs::parse_from(["radroots", "listing", "publish", "draft.toml"]);
+ match listing_publish.command {
+ Command::Listing(args) => match args.command {
+ ListingCommand::Publish(file) => {
+ assert_eq!(file.file.to_str(), Some("draft.toml"));
+ }
+ _ => panic!("unexpected listing subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let listing_get = CliArgs::parse_from(["radroots", "listing", "get", "lst_123"]);
+ match listing_get.command {
Command::Listing(args) => match args.command {
ListingCommand::Get(key) => assert_eq!(key.key, "lst_123"),
_ => panic!("unexpected listing subcommand"),
diff --git a/src/commands/listing.rs b/src/commands/listing.rs
@@ -0,0 +1,36 @@
+use crate::cli::{ListingFileArgs, ListingNewArgs, RecordKeyArgs};
+use crate::domain::runtime::{CommandOutput, CommandView};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn new(config: &RuntimeConfig, args: &ListingNewArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::listing::scaffold(config, args)?;
+ Ok(CommandOutput::success(CommandView::ListingNew(view)))
+}
+
+pub fn validate(
+ config: &RuntimeConfig,
+ args: &ListingFileArgs,
+) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::listing::validate(config, args)?;
+ Ok(CommandOutput::success(CommandView::ListingValidate(view)))
+}
+
+pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::listing::get(config, args)?;
+ let output = match view.disposition() {
+ crate::domain::runtime::CommandDisposition::Success => {
+ CommandOutput::success(CommandView::ListingGet(view))
+ }
+ crate::domain::runtime::CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::ListingGet(view))
+ }
+ crate::domain::runtime::CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::ListingGet(view))
+ }
+ crate::domain::runtime::CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::ListingGet(view))
+ }
+ };
+ Ok(output)
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -1,6 +1,7 @@
pub mod doctor;
pub mod find;
pub mod identity;
+pub mod listing;
pub mod local;
pub mod myc;
pub mod net;
@@ -53,10 +54,10 @@ pub fn dispatch(
JobCommand::Watch(_) => unimplemented_command("job watch"),
},
Command::Listing(listing) => match &listing.command {
- ListingCommand::New => unimplemented_command("listing new"),
- ListingCommand::Validate => unimplemented_command("listing validate"),
- ListingCommand::Get(_) => unimplemented_command("listing get"),
- ListingCommand::Publish => unimplemented_command("listing publish"),
+ ListingCommand::New(args) => listing::new(config, args),
+ ListingCommand::Validate(args) => listing::validate(config, args),
+ ListingCommand::Get(args) => listing::get(config, args),
+ ListingCommand::Publish(_) => unimplemented_command("listing publish"),
ListingCommand::Update(_) => unimplemented_command("listing update"),
ListingCommand::Archive(_) => unimplemented_command("listing archive"),
},
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -75,6 +75,9 @@ pub enum CommandView {
ConfigShow(ConfigShowView),
Doctor(DoctorView),
Find(FindView),
+ ListingGet(ListingGetView),
+ ListingNew(ListingNewView),
+ ListingValidate(ListingValidateView),
LocalBackup(LocalBackupView),
LocalExport(LocalExportView),
LocalInit(LocalInitView),
@@ -353,6 +356,85 @@ impl FindView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct ListingNewView {
+ pub state: String,
+ pub source: String,
+ pub file: String,
+ pub listing_id: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub selected_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_d_tag: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ListingValidateView {
+ pub state: String,
+ pub source: String,
+ pub file: String,
+ pub valid: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub farm_d_tag: 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>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ListingValidationIssueView {
+ pub field: String,
+ pub message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub line: Option<usize>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ListingGetView {
+ pub state: String,
+ pub source: String,
+ pub lookup: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_id: Option<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 description: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub location_primary: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub available: Option<FindQuantityView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub price: Option<FindPriceView>,
+ pub provenance: FindResultProvenanceView,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl ListingGetView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct FindResultView {
pub id: String,
pub product_key: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -2,8 +2,9 @@ use std::io::{self, Write};
use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
- FindView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView,
- RelayListView, SyncActionView, SyncStatusView, SyncWatchView,
+ FindView, ListingGetView, ListingNewView, ListingValidateView, LocalBackupView,
+ LocalExportView, LocalInitView, LocalStatusView, NetStatusView, RelayListView, SyncActionView,
+ SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -85,6 +86,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::Find(view) => {
render_find(stdout, view)?;
}
+ CommandView::ListingGet(view) => {
+ render_listing_get(stdout, view)?;
+ }
+ CommandView::ListingNew(view) => {
+ render_listing_new(stdout, view)?;
+ }
+ CommandView::ListingValidate(view) => {
+ render_listing_validate(stdout, view)?;
+ }
CommandView::LocalBackup(view) => {
render_local_backup(stdout, view)?;
}
@@ -191,6 +201,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::ListingGet(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::ListingNew(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::ListingValidate(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::LocalBackup(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -492,6 +514,162 @@ fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeErr
Ok(())
}
+fn render_listing_new(
+ stdout: &mut dyn Write,
+ view: &ListingNewView,
+) -> Result<(), RuntimeError> {
+ write_context(stdout, "listing · draft created")?;
+ let mut rows = vec![
+ ("file", view.file.as_str()),
+ ("listing id", view.listing_id.as_str()),
+ ];
+ if let Some(account_id) = &view.selected_account_id {
+ rows.push(("account id", account_id.as_str()));
+ }
+ if let Some(seller_pubkey) = &view.seller_pubkey {
+ rows.push(("seller", seller_pubkey.as_str()));
+ }
+ if let Some(farm_d_tag) = &view.farm_d_tag {
+ rows.push(("farm d_tag", farm_d_tag.as_str()));
+ }
+ render_pairs(stdout, "draft", rows.as_slice())?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_listing_validate(
+ stdout: &mut dyn Write,
+ view: &ListingValidateView,
+) -> Result<(), RuntimeError> {
+ write_context(
+ stdout,
+ match view.state.as_str() {
+ "valid" => "listing · valid",
+ _ => "listing · invalid",
+ },
+ )?;
+ let status = if view.valid {
+ "ready to publish"
+ } else {
+ "needs edits"
+ };
+ let mut rows = vec![("file", view.file.as_str()), ("status", status)];
+ if let Some(listing_id) = &view.listing_id {
+ rows.push(("listing id", listing_id.as_str()));
+ }
+ if let Some(seller_pubkey) = &view.seller_pubkey {
+ rows.push(("seller", seller_pubkey.as_str()));
+ }
+ if let Some(farm_d_tag) = &view.farm_d_tag {
+ rows.push(("farm d_tag", farm_d_tag.as_str()));
+ }
+ render_pairs(stdout, "validation", rows.as_slice())?;
+ if !view.issues.is_empty() {
+ writeln!(stdout, "issues")?;
+ for issue in &view.issues {
+ match issue.line {
+ Some(line) => writeln!(
+ stdout,
+ " {field} {message} (line {line})",
+ field = issue.field,
+ message = issue.message
+ )?,
+ None => writeln!(
+ stdout,
+ " {field} {message}",
+ field = issue.field,
+ message = issue.message
+ )?,
+ }
+ }
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_listing_get(
+ stdout: &mut dyn Write,
+ view: &ListingGetView,
+) -> Result<(), RuntimeError> {
+ let context = view
+ .listing_id
+ .clone()
+ .unwrap_or_else(|| view.lookup.clone());
+ write_context(stdout, format!("listing · {context}").as_str())?;
+
+ match view.state.as_str() {
+ "unconfigured" | "missing" => {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ }
+ }
+ _ => {
+ if let Some(title) = &view.title {
+ writeln!(stdout, "{title}")?;
+ writeln!(stdout)?;
+ }
+ let mut rows = Vec::<(&str, String)>::new();
+ if let Some(product_key) = &view.product_key {
+ rows.push(("key", product_key.clone()));
+ }
+ if let Some(category) = &view.category {
+ rows.push(("category", category.clone()));
+ }
+ if let Some(price) = &view.price {
+ rows.push((
+ "price",
+ format_price(
+ price.amount,
+ &price.currency,
+ price.per_amount,
+ &price.per_unit,
+ ),
+ ));
+ }
+ if let Some(available) = &view.available {
+ rows.push((
+ "available",
+ format_available(
+ available.available_amount.unwrap_or(available.total_amount),
+ available
+ .label
+ .as_deref()
+ .unwrap_or(available.total_unit.as_str()),
+ ),
+ ));
+ }
+ if let Some(location_primary) = &view.location_primary {
+ rows.push(("location", location_primary.clone()));
+ }
+ if let Some(listing_id) = &view.listing_id {
+ rows.push(("listing id", listing_id.clone()));
+ }
+ render_owned_pairs(stdout, "listing", rows.as_slice())?;
+ if let Some(description) = &view.description {
+ writeln!(stdout, "{description}")?;
+ writeln!(stdout)?;
+ }
+ writeln!(
+ stdout,
+ "provenance: local replica · {} · {}",
+ view.provenance.freshness,
+ relay_count_text(view.provenance.relay_count)
+ )?;
+ writeln!(stdout, "source: {}", view.source)?;
+ }
+ }
+
+ if view.state != "ready" {
+ writeln!(stdout)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ }
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
fn render_relay_list(stdout: &mut dyn Write, view: &RelayListView) -> Result<(), RuntimeError> {
write_context(
stdout,
@@ -1000,6 +1178,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::ConfigShow(_) => "config show",
CommandView::Doctor(_) => "doctor",
CommandView::Find(_) => "find",
+ CommandView::ListingGet(_) => "listing get",
+ CommandView::ListingNew(_) => "listing new",
+ CommandView::ListingValidate(_) => "listing validate",
CommandView::LocalBackup(_) => "local backup",
CommandView::LocalExport(_) => "local export",
CommandView::LocalInit(_) => "local init",
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -0,0 +1,1045 @@
+use std::fs;
+use std::path::Path;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use radroots_core::{
+ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity,
+ RadrootsCoreQuantityPrice, RadrootsCoreUnit,
+};
+use radroots_events::RadrootsNostrEvent;
+use radroots_events::kinds::KIND_LISTING_DRAFT;
+use radroots_events::listing::{
+ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
+ RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation,
+ RadrootsListingProduct, RadrootsListingStatus,
+};
+use radroots_events::trade::RadrootsTradeListingValidationError;
+use radroots_events_codec::d_tag::is_d_tag_base64url;
+use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
+use radroots_sql_core::{SqlExecutor, SqliteExecutor, utils};
+use radroots_trade::listing::validation::validate_listing_event;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::cli::{ListingFileArgs, ListingNewArgs, RecordKeyArgs};
+use crate::domain::runtime::{
+ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView,
+ ListingNewView, ListingValidationIssueView, ListingValidateView, SyncFreshnessView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::accounts;
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::sync::freshness_from_executor;
+
+const DRAFT_KIND: &str = "listing_draft_v1";
+const LISTING_SOURCE: &str = "local draft · local first";
+const LISTING_READ_SOURCE: &str = "local replica · local first";
+
+static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftDocument {
+ version: u32,
+ kind: String,
+ listing: ListingDraftMeta,
+ product: ListingDraftProduct,
+ primary_bin: ListingDraftPrimaryBin,
+ inventory: ListingDraftInventory,
+ availability: ListingDraftAvailability,
+ delivery: ListingDraftDelivery,
+ location: ListingDraftLocation,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftMeta {
+ d_tag: String,
+ farm_d_tag: String,
+ seller_pubkey: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftProduct {
+ key: String,
+ title: String,
+ category: String,
+ summary: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftPrimaryBin {
+ bin_id: String,
+ quantity_amount: String,
+ quantity_unit: String,
+ price_amount: String,
+ price_currency: String,
+ price_per_amount: String,
+ price_per_unit: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ label: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftInventory {
+ available: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftAvailability {
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ kind: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ status: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ start: Option<u64>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ end: Option<u64>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftDelivery {
+ method: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ListingDraftLocation {
+ primary: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ city: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ region: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ country: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+struct ListingValidationContext {
+ selected_account_id: Option<String>,
+ selected_account_pubkey: Option<String>,
+ selected_farm_d_tag: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+struct CanonicalListingDraft {
+ listing_id: String,
+ seller_pubkey: String,
+ farm_d_tag: String,
+ listing: RadrootsListing,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct ListingRow {
+ id: String,
+ key: String,
+ category: String,
+ title: String,
+ summary: String,
+ qty_amt: i64,
+ qty_unit: String,
+ qty_label: Option<String>,
+ qty_avail: Option<i64>,
+ price_amt: f64,
+ price_currency: String,
+ price_qty_amt: u32,
+ price_qty_unit: String,
+ location_primary: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct FarmRow {
+ d_tag: String,
+}
+
+pub fn scaffold(
+ config: &RuntimeConfig,
+ args: &ListingNewArgs,
+) -> Result<ListingNewView, RuntimeError> {
+ let selected_account = accounts::resolve_account(config)?;
+ let seller_pubkey = selected_account
+ .as_ref()
+ .map(|account| account.record.public_identity.public_key_hex.clone());
+ let farm_d_tag = match seller_pubkey.as_deref() {
+ Some(pubkey) => resolve_selected_farm_d_tag(config, pubkey)?,
+ None => None,
+ };
+
+ let draft = ListingDraftDocument {
+ version: 1,
+ kind: DRAFT_KIND.to_owned(),
+ listing: ListingDraftMeta {
+ d_tag: generate_d_tag(),
+ farm_d_tag: farm_d_tag.clone().unwrap_or_default(),
+ seller_pubkey: seller_pubkey.clone().unwrap_or_default(),
+ },
+ product: ListingDraftProduct {
+ key: String::new(),
+ title: String::new(),
+ category: String::new(),
+ summary: String::new(),
+ },
+ primary_bin: ListingDraftPrimaryBin {
+ bin_id: "bin-1".to_owned(),
+ quantity_amount: "1000".to_owned(),
+ quantity_unit: "g".to_owned(),
+ price_amount: "0.01".to_owned(),
+ price_currency: "USD".to_owned(),
+ price_per_amount: "1".to_owned(),
+ price_per_unit: "g".to_owned(),
+ label: String::new(),
+ },
+ inventory: ListingDraftInventory {
+ available: "1".to_owned(),
+ },
+ availability: ListingDraftAvailability {
+ kind: "status".to_owned(),
+ status: "active".to_owned(),
+ start: None,
+ end: None,
+ },
+ delivery: ListingDraftDelivery {
+ method: "pickup".to_owned(),
+ },
+ location: ListingDraftLocation {
+ primary: String::new(),
+ city: None,
+ region: None,
+ country: None,
+ },
+ };
+
+ let output_path = match &args.output {
+ Some(path) => path.clone(),
+ None => std::env::current_dir()?.join(format!("listing-{}.toml", draft.listing.d_tag)),
+ };
+ if output_path.exists() {
+ return Err(RuntimeError::Config(format!(
+ "listing draft output {} already exists",
+ output_path.display()
+ )));
+ }
+ if let Some(parent) = output_path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ fs::write(&output_path, scaffold_contents(&draft)?)?;
+
+ let mut actions = vec![format!(
+ "radroots listing validate {}",
+ output_path.display()
+ )];
+ if seller_pubkey.is_none() {
+ actions.push("radroots account new".to_owned());
+ }
+ if farm_d_tag.is_none() {
+ actions.push("radroots sync status".to_owned());
+ }
+
+ 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: selected_account.map(|account| account.record.account_id.to_string()),
+ seller_pubkey,
+ farm_d_tag,
+ actions,
+ })
+}
+
+pub fn validate(
+ config: &RuntimeConfig,
+ args: &ListingFileArgs,
+) -> Result<ListingValidateView, RuntimeError> {
+ let contents = fs::read_to_string(&args.file)?;
+ let context = validation_context(config)?;
+
+ let parsed = match toml::from_str::<ListingDraftDocument>(&contents) {
+ Ok(parsed) => parsed,
+ Err(error) => {
+ return Ok(ListingValidateView {
+ state: "invalid".to_owned(),
+ source: LISTING_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ valid: false,
+ listing_id: None,
+ seller_pubkey: context.selected_account_pubkey.clone(),
+ farm_d_tag: context.selected_farm_d_tag.clone(),
+ issues: vec![ListingValidationIssueView {
+ field: "toml".to_owned(),
+ message: error.to_string(),
+ line: error
+ .span()
+ .map(|span| line_for_offset(&contents, span.start + 1)),
+ }],
+ actions: vec![format!("edit {}", args.file.display())],
+ });
+ }
+ };
+
+ match canonicalize_draft(&parsed, &contents, &context) {
+ Ok(canonical) => {
+ let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) {
+ Ok(parts) => parts,
+ Err(error) => {
+ return Ok(invalid_validation_view(
+ args.file.as_path(),
+ parsed.listing.d_tag.as_str(),
+ &context,
+ ListingValidationIssueView {
+ field: "listing".to_owned(),
+ message: format!("invalid listing contract: {error}"),
+ line: None,
+ },
+ ));
+ }
+ };
+ let event = RadrootsNostrEvent {
+ id: String::new(),
+ author: canonical.seller_pubkey.clone(),
+ created_at: 0,
+ kind: KIND_LISTING_DRAFT,
+ tags: parts.tags,
+ content: parts.content,
+ sig: String::new(),
+ };
+ match validate_listing_event(&event) {
+ Ok(_) => Ok(ListingValidateView {
+ state: "valid".to_owned(),
+ source: LISTING_SOURCE.to_owned(),
+ file: args.file.display().to_string(),
+ valid: true,
+ listing_id: Some(canonical.listing_id),
+ seller_pubkey: Some(canonical.seller_pubkey),
+ farm_d_tag: Some(canonical.farm_d_tag),
+ issues: Vec::new(),
+ actions: vec![format!("radroots listing publish {}", args.file.display())],
+ }),
+ Err(error) => Ok(invalid_validation_view(
+ args.file.as_path(),
+ parsed.listing.d_tag.as_str(),
+ &context,
+ issue_from_trade_validation(error, &contents),
+ )),
+ }
+ }
+ Err(issue) => Ok(invalid_validation_view(
+ args.file.as_path(),
+ parsed.listing.d_tag.as_str(),
+ &context,
+ issue,
+ )),
+ }
+}
+
+pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<ListingGetView, RuntimeError> {
+ let freshness = if config.local.replica_db_path.exists() {
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ freshness_from_executor(&executor)?
+ } else {
+ SyncFreshnessView {
+ state: "never".to_owned(),
+ display: "never synced".to_owned(),
+ age_seconds: None,
+ last_event_at: None,
+ }
+ };
+ let provenance = FindResultProvenanceView {
+ origin: "local_replica.trade_product".to_owned(),
+ freshness: freshness.display.clone(),
+ relay_count: config.relay.urls.len(),
+ };
+
+ if !config.local.replica_db_path.exists() {
+ return Ok(ListingGetView {
+ state: "unconfigured".to_owned(),
+ source: LISTING_READ_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ listing_id: None,
+ product_key: None,
+ title: None,
+ category: None,
+ description: None,
+ location_primary: None,
+ available: None,
+ price: None,
+ provenance,
+ reason: Some("local replica database is not initialized".to_owned()),
+ actions: vec!["radroots local init".to_owned()],
+ });
+ }
+
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ let rows = query_listing_rows(&executor, args.key.as_str())?;
+ let Some(row) = rows.into_iter().next() else {
+ return Ok(ListingGetView {
+ state: "missing".to_owned(),
+ source: LISTING_READ_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ listing_id: None,
+ product_key: None,
+ title: None,
+ category: None,
+ description: None,
+ location_primary: None,
+ available: None,
+ price: None,
+ provenance,
+ reason: Some(format!(
+ "listing `{}` is not available in the local replica",
+ args.key
+ )),
+ actions: vec![
+ "radroots sync pull".to_owned(),
+ format!("radroots find {}", args.key),
+ ],
+ });
+ };
+
+ Ok(ListingGetView {
+ state: "ready".to_owned(),
+ source: LISTING_READ_SOURCE.to_owned(),
+ lookup: args.key.clone(),
+ listing_id: Some(row.id),
+ product_key: Some(row.key),
+ title: Some(row.title),
+ category: Some(row.category),
+ description: non_empty(row.summary),
+ location_primary: row.location_primary.and_then(non_empty),
+ available: Some(FindQuantityView {
+ total_amount: row.qty_amt,
+ total_unit: row.qty_unit,
+ label: row.qty_label.and_then(non_empty),
+ available_amount: row.qty_avail,
+ }),
+ price: Some(FindPriceView {
+ amount: row.price_amt,
+ currency: row.price_currency,
+ per_amount: row.price_qty_amt,
+ per_unit: row.price_qty_unit,
+ }),
+ provenance,
+ reason: None,
+ actions: Vec::new(),
+ })
+}
+
+fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> {
+ let toml = toml::to_string_pretty(draft)
+ .map_err(|error| RuntimeError::Config(format!("failed to render listing draft: {error}")))?;
+ Ok(format!(
+ "# radroots listing draft v1\n# fill the empty fields, then run `radroots listing validate <file>`\n\n{toml}"
+ ))
+}
+
+fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> {
+ let selected_account = accounts::resolve_account(config)?;
+ let selected_account_pubkey = selected_account
+ .as_ref()
+ .map(|account| account.record.public_identity.public_key_hex.clone());
+ let selected_farm_d_tag = match selected_account_pubkey.as_deref() {
+ Some(pubkey) => resolve_selected_farm_d_tag(config, pubkey)?,
+ None => None,
+ };
+ Ok(ListingValidationContext {
+ selected_account_id: selected_account.map(|account| account.record.account_id.to_string()),
+ selected_account_pubkey,
+ selected_farm_d_tag,
+ })
+}
+
+fn canonicalize_draft(
+ draft: &ListingDraftDocument,
+ contents: &str,
+ context: &ListingValidationContext,
+) -> Result<CanonicalListingDraft, ListingValidationIssueView> {
+ if draft.version != 1 {
+ return Err(issue_for_field(
+ contents,
+ "version",
+ format!("unsupported listing draft version `{}`", draft.version),
+ ));
+ }
+ if draft.kind.trim() != DRAFT_KIND {
+ return Err(issue_for_field(
+ contents,
+ "kind",
+ format!("unsupported listing draft kind `{}`", draft.kind),
+ ));
+ }
+
+ let listing_id = draft.listing.d_tag.trim().to_owned();
+ if !is_d_tag_base64url(&listing_id) {
+ return Err(issue_for_field(
+ contents,
+ "listing.d_tag",
+ "listing d_tag must be a 22-character base64url identifier",
+ ));
+ }
+
+ let seller_pubkey = if let Some(pubkey) = non_empty(draft.listing.seller_pubkey.clone()) {
+ pubkey
+ } else if let Some(pubkey) = context.selected_account_pubkey.clone() {
+ pubkey
+ } else {
+ return Err(issue_for_field(
+ contents,
+ "listing.seller_pubkey",
+ "missing seller_pubkey and no local account is selected",
+ ));
+ };
+
+ let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) {
+ d_tag
+ } else if let Some(d_tag) = context.selected_farm_d_tag.clone() {
+ d_tag
+ } else {
+ return Err(issue_for_field(
+ contents,
+ "listing.farm_d_tag",
+ "missing farm_d_tag and no matching local farm was found for the selected account",
+ ));
+ };
+ if !is_d_tag_base64url(&farm_d_tag) {
+ return Err(issue_for_field(
+ contents,
+ "listing.farm_d_tag",
+ "farm_d_tag must be a 22-character base64url identifier",
+ ));
+ }
+
+ let quantity_amount = parse_decimal_field(
+ draft.primary_bin.quantity_amount.as_str(),
+ contents,
+ "primary_bin.quantity_amount",
+ )?;
+ let quantity_unit = parse_unit_field(
+ draft.primary_bin.quantity_unit.as_str(),
+ contents,
+ "primary_bin.quantity_unit",
+ )?;
+ let quantity = RadrootsCoreQuantity::new(quantity_amount, quantity_unit)
+ .with_optional_label(non_empty(draft.primary_bin.label.clone()))
+ .to_canonical()
+ .map_err(|error| issue_for_field(
+ contents,
+ "primary_bin.quantity_unit",
+ format!("invalid primary_bin quantity unit conversion: {error}"),
+ ))?;
+
+ let price_amount = parse_decimal_field(
+ draft.primary_bin.price_amount.as_str(),
+ contents,
+ "primary_bin.price_amount",
+ )?;
+ let price_currency = parse_currency_field(
+ draft.primary_bin.price_currency.as_str(),
+ contents,
+ "primary_bin.price_currency",
+ )?;
+ let price_per_amount = parse_decimal_field(
+ draft.primary_bin.price_per_amount.as_str(),
+ contents,
+ "primary_bin.price_per_amount",
+ )?;
+ let price_per_unit = parse_unit_field(
+ draft.primary_bin.price_per_unit.as_str(),
+ contents,
+ "primary_bin.price_per_unit",
+ )?;
+ let price = RadrootsCoreQuantityPrice::new(
+ RadrootsCoreMoney::new(price_amount, price_currency),
+ RadrootsCoreQuantity::new(price_per_amount, price_per_unit),
+ )
+ .try_to_canonical_unit_price()
+ .map_err(|error| {
+ issue_for_field(
+ contents,
+ "primary_bin.price_per_unit",
+ format!("invalid primary_bin price definition: {error:?}"),
+ )
+ })?;
+
+ let inventory_available = parse_decimal_field(
+ draft.inventory.available.as_str(),
+ contents,
+ "inventory.available",
+ )?;
+ let availability = build_availability(draft, contents)?;
+ let delivery_method = build_delivery_method(draft, contents)?;
+ let location = build_location(draft);
+
+ let listing = RadrootsListing {
+ d_tag: listing_id.clone(),
+ farm: RadrootsListingFarmRef {
+ pubkey: seller_pubkey.clone(),
+ d_tag: farm_d_tag.clone(),
+ },
+ product: RadrootsListingProduct {
+ key: draft.product.key.trim().to_owned(),
+ title: draft.product.title.trim().to_owned(),
+ category: draft.product.category.trim().to_owned(),
+ summary: non_empty(draft.product.summary.clone()),
+ process: None,
+ lot: None,
+ location: None,
+ profile: None,
+ year: None,
+ },
+ primary_bin_id: draft.primary_bin.bin_id.trim().to_owned(),
+ bins: vec![RadrootsListingBin {
+ bin_id: draft.primary_bin.bin_id.trim().to_owned(),
+ quantity,
+ price_per_canonical_unit: price,
+ display_amount: None,
+ display_unit: None,
+ display_label: non_empty(draft.primary_bin.label.clone()),
+ display_price: None,
+ display_price_unit: None,
+ }],
+ resource_area: None,
+ plot: None,
+ discounts: None,
+ inventory_available: Some(inventory_available),
+ availability: Some(availability),
+ delivery_method: Some(delivery_method),
+ location: Some(location),
+ images: None,
+ };
+
+ Ok(CanonicalListingDraft {
+ listing_id,
+ seller_pubkey,
+ farm_d_tag,
+ listing,
+ })
+}
+
+fn build_availability(
+ draft: &ListingDraftDocument,
+ contents: &str,
+) -> Result<RadrootsListingAvailability, ListingValidationIssueView> {
+ let kind = if draft.availability.kind.trim().is_empty() {
+ if draft.availability.start.is_some() || draft.availability.end.is_some() {
+ "window"
+ } else {
+ "status"
+ }
+ } else {
+ draft.availability.kind.trim()
+ };
+
+ match kind {
+ "status" => {
+ let status = draft.availability.status.trim();
+ if status.is_empty() {
+ return Err(issue_for_field(
+ contents,
+ "availability.status",
+ "missing availability status",
+ ));
+ }
+ Ok(RadrootsListingAvailability::Status {
+ status: match status {
+ "active" => RadrootsListingStatus::Active,
+ "sold" => RadrootsListingStatus::Sold,
+ other => RadrootsListingStatus::Other {
+ value: other.to_owned(),
+ },
+ },
+ })
+ }
+ "window" => Ok(RadrootsListingAvailability::Window {
+ start: draft.availability.start,
+ end: draft.availability.end,
+ }),
+ _ => Err(issue_for_field(
+ contents,
+ "availability.kind",
+ format!("unsupported availability kind `{kind}`"),
+ )),
+ }
+}
+
+fn build_delivery_method(
+ draft: &ListingDraftDocument,
+ contents: &str,
+) -> Result<RadrootsListingDeliveryMethod, ListingValidationIssueView> {
+ let method = draft.delivery.method.trim();
+ if method.is_empty() {
+ return Err(issue_for_field(
+ contents,
+ "delivery.method",
+ "missing delivery method",
+ ));
+ }
+
+ Ok(match method {
+ "pickup" => RadrootsListingDeliveryMethod::Pickup,
+ "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
+ "shipping" => RadrootsListingDeliveryMethod::Shipping,
+ other => RadrootsListingDeliveryMethod::Other {
+ method: other.to_owned(),
+ },
+ })
+}
+
+fn build_location(draft: &ListingDraftDocument) -> RadrootsListingLocation {
+ RadrootsListingLocation {
+ primary: draft.location.primary.trim().to_owned(),
+ city: draft.location.city.clone().and_then(non_empty),
+ region: draft.location.region.clone().and_then(non_empty),
+ country: draft.location.country.clone().and_then(non_empty),
+ lat: None,
+ lng: None,
+ geohash: None,
+ }
+}
+
+fn invalid_validation_view(
+ file: &Path,
+ listing_id: &str,
+ context: &ListingValidationContext,
+ issue: ListingValidationIssueView,
+) -> ListingValidateView {
+ let mut actions = vec![format!("edit {}", file.display())];
+ if context.selected_account_id.is_none() {
+ actions.push("radroots account new".to_owned());
+ }
+ if context.selected_farm_d_tag.is_none() {
+ actions.push("radroots sync status".to_owned());
+ }
+
+ ListingValidateView {
+ state: "invalid".to_owned(),
+ source: LISTING_SOURCE.to_owned(),
+ file: file.display().to_string(),
+ valid: false,
+ listing_id: non_empty(listing_id.to_owned()),
+ seller_pubkey: context.selected_account_pubkey.clone(),
+ farm_d_tag: context.selected_farm_d_tag.clone(),
+ issues: vec![issue],
+ actions,
+ }
+}
+
+fn issue_from_trade_validation(
+ error: RadrootsTradeListingValidationError,
+ contents: &str,
+) -> ListingValidationIssueView {
+ match error {
+ RadrootsTradeListingValidationError::InvalidSeller => issue_for_field(
+ contents,
+ "listing.seller_pubkey",
+ "listing author does not match the farm pubkey",
+ ),
+ RadrootsTradeListingValidationError::MissingTitle => issue_for_field(
+ contents,
+ "product.title",
+ "missing listing title",
+ ),
+ RadrootsTradeListingValidationError::MissingDescription => issue_for_field(
+ contents,
+ "product.summary",
+ "missing listing description",
+ ),
+ RadrootsTradeListingValidationError::MissingProductType => issue_for_field(
+ contents,
+ "product.category",
+ "missing listing product type",
+ ),
+ RadrootsTradeListingValidationError::MissingBins
+ | RadrootsTradeListingValidationError::MissingPrimaryBin
+ | RadrootsTradeListingValidationError::InvalidBin => issue_for_field(
+ contents,
+ "primary_bin.bin_id",
+ error.to_string(),
+ ),
+ RadrootsTradeListingValidationError::InvalidPrice => issue_for_field(
+ contents,
+ "primary_bin.price_amount",
+ "invalid listing price",
+ ),
+ RadrootsTradeListingValidationError::MissingInventory
+ | RadrootsTradeListingValidationError::InvalidInventory => issue_for_field(
+ contents,
+ "inventory.available",
+ error.to_string(),
+ ),
+ RadrootsTradeListingValidationError::MissingAvailability => issue_for_field(
+ contents,
+ "availability.status",
+ "missing listing availability",
+ ),
+ RadrootsTradeListingValidationError::MissingLocation => issue_for_field(
+ contents,
+ "location.primary",
+ "missing listing location",
+ ),
+ RadrootsTradeListingValidationError::MissingDeliveryMethod => issue_for_field(
+ contents,
+ "delivery.method",
+ "missing listing delivery method",
+ ),
+ other => issue_for_field(contents, "listing", other.to_string()),
+ }
+}
+
+fn query_listing_rows(
+ executor: &SqliteExecutor,
+ lookup: &str,
+) -> Result<Vec<ListingRow>, RuntimeError> {
+ let sql =
+ "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \
+ FROM trade_product tp \
+ LEFT JOIN (\
+ SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \
+ FROM trade_product_location tpl \
+ JOIN gcs_location gl ON gl.id = tpl.tb_gl \
+ GROUP BY tpl.tb_tp\
+ ) loc ON loc.trade_product_id = tp.id \
+ WHERE tp.id = ? OR tp.key = ? \
+ ORDER BY lower(tp.title) ASC, tp.id ASC;";
+ let params = utils::to_params_json(vec![
+ Value::from(lookup.to_owned()),
+ Value::from(lookup.to_owned()),
+ ])?;
+ let raw = executor.query_raw(sql, ¶ms)?;
+ serde_json::from_str(&raw).map_err(RuntimeError::from)
+}
+
+fn resolve_selected_farm_d_tag(
+ config: &RuntimeConfig,
+ seller_pubkey: &str,
+) -> Result<Option<String>, RuntimeError> {
+ if !config.local.replica_db_path.exists() {
+ return Ok(None);
+ }
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ let sql = "SELECT d_tag FROM farm WHERE pubkey = ? ORDER BY d_tag ASC;";
+ let params = utils::to_params_json(vec![Value::from(seller_pubkey.to_owned())])?;
+ let raw = executor.query_raw(sql, ¶ms)?;
+ let rows: Vec<FarmRow> = serde_json::from_str(&raw).map_err(RuntimeError::from)?;
+ if rows.len() == 1 {
+ Ok(Some(rows[0].d_tag.clone()))
+ } else {
+ Ok(None)
+ }
+}
+
+fn parse_decimal_field(
+ value: &str,
+ contents: &str,
+ field: &str,
+) -> Result<RadrootsCoreDecimal, ListingValidationIssueView> {
+ value.trim().parse::<RadrootsCoreDecimal>().map_err(|_| {
+ issue_for_field(
+ contents,
+ field,
+ format!("`{field}` must be a valid decimal value"),
+ )
+ })
+}
+
+fn parse_unit_field(
+ value: &str,
+ contents: &str,
+ field: &str,
+) -> Result<RadrootsCoreUnit, ListingValidationIssueView> {
+ value.parse::<RadrootsCoreUnit>().map_err(|_| {
+ issue_for_field(contents, field, format!("`{field}` must be a valid unit code"))
+ })
+}
+
+fn parse_currency_field(
+ value: &str,
+ contents: &str,
+ field: &str,
+) -> Result<RadrootsCoreCurrency, ListingValidationIssueView> {
+ let upper = value.trim().to_ascii_uppercase();
+ RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| {
+ issue_for_field(
+ contents,
+ field,
+ format!("`{field}` must be a valid ISO currency code"),
+ )
+ })
+}
+
+fn issue_for_field(
+ contents: &str,
+ field: &str,
+ message: impl Into<String>,
+) -> ListingValidationIssueView {
+ ListingValidationIssueView {
+ field: field.to_owned(),
+ message: message.into(),
+ line: line_for_field(contents, field),
+ }
+}
+
+fn line_for_field(contents: &str, field: &str) -> Option<usize> {
+ let needles: &[&str] = match field {
+ "version" => &["version ="],
+ "kind" => &["kind ="],
+ "listing.d_tag" => &["d_tag ="],
+ "listing.farm_d_tag" => &["farm_d_tag ="],
+ "listing.seller_pubkey" => &["seller_pubkey ="],
+ "product.key" => &["key ="],
+ "product.title" => &["title ="],
+ "product.category" => &["category ="],
+ "product.summary" => &["summary ="],
+ "primary_bin.bin_id" => &["bin_id ="],
+ "primary_bin.quantity_amount" => &["quantity_amount ="],
+ "primary_bin.quantity_unit" => &["quantity_unit ="],
+ "primary_bin.price_amount" => &["price_amount ="],
+ "primary_bin.price_currency" => &["price_currency ="],
+ "primary_bin.price_per_amount" => &["price_per_amount ="],
+ "primary_bin.price_per_unit" => &["price_per_unit ="],
+ "inventory.available" => &["available ="],
+ "availability.kind" => &["[availability]", "kind ="],
+ "availability.status" => &["status ="],
+ "delivery.method" => &["method ="],
+ "location.primary" => &["primary ="],
+ _ => &[],
+ };
+ for needle in needles {
+ if let Some(line) = contents.lines().position(|line| line.contains(needle)) {
+ return Some(line + 1);
+ }
+ }
+ None
+}
+
+fn line_for_offset(contents: &str, offset: usize) -> usize {
+ let mut seen = 0usize;
+ for (index, line) in contents.lines().enumerate() {
+ seen += line.len() + 1;
+ if seen >= offset {
+ return index + 1;
+ }
+ }
+ contents.lines().count().max(1)
+}
+
+fn non_empty(value: String) -> Option<String> {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+fn generate_d_tag() -> String {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or_default();
+ let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
+ let mixed = nanos ^ counter;
+ encode_base64url_no_pad(mixed.to_be_bytes())
+}
+
+fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
+ const ALPHABET: &[u8; 64] =
+ b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ let mut output = String::with_capacity(22);
+ let mut index = 0usize;
+ while index + 3 <= bytes.len() {
+ let block = ((bytes[index] as u32) << 16)
+ | ((bytes[index + 1] as u32) << 8)
+ | (bytes[index + 2] as u32);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ output.push(ALPHABET[(block & 0x3f) as usize] as char);
+ index += 3;
+ }
+ let remaining = bytes.len() - index;
+ if remaining == 1 {
+ let block = (bytes[index] as u32) << 16;
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ } else if remaining == 2 {
+ let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ }
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag};
+ use radroots_events_codec::d_tag::is_d_tag_base64url;
+
+ #[test]
+ fn generated_listing_d_tag_is_valid_base64url() {
+ let d_tag = generate_d_tag();
+ assert!(is_d_tag_base64url(&d_tag));
+ }
+
+ #[test]
+ fn base64url_encoder_produces_twenty_two_characters_for_sixteen_bytes() {
+ let encoded = encode_base64url_no_pad([0u8; 16]);
+ assert_eq!(encoded.len(), 22);
+ assert!(is_d_tag_base64url(&encoded));
+ }
+
+ #[test]
+ fn listing_draft_kind_constant_is_stable() {
+ let document = ListingDraftDocument {
+ version: 1,
+ kind: DRAFT_KIND.to_owned(),
+ listing: super::ListingDraftMeta {
+ d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(),
+ seller_pubkey: "a".repeat(64),
+ },
+ product: super::ListingDraftProduct {
+ key: "sku".to_owned(),
+ title: "Widget".to_owned(),
+ category: "produce".to_owned(),
+ summary: "Fresh".to_owned(),
+ },
+ primary_bin: super::ListingDraftPrimaryBin {
+ bin_id: "bin-1".to_owned(),
+ quantity_amount: "1".to_owned(),
+ quantity_unit: "kg".to_owned(),
+ price_amount: "12.50".to_owned(),
+ price_currency: "USD".to_owned(),
+ price_per_amount: "1".to_owned(),
+ price_per_unit: "kg".to_owned(),
+ label: "kg".to_owned(),
+ },
+ inventory: super::ListingDraftInventory {
+ available: "2".to_owned(),
+ },
+ availability: super::ListingDraftAvailability {
+ kind: "status".to_owned(),
+ status: "active".to_owned(),
+ start: None,
+ end: None,
+ },
+ delivery: super::ListingDraftDelivery {
+ method: "pickup".to_owned(),
+ },
+ location: super::ListingDraftLocation {
+ primary: "Asheville".to_owned(),
+ city: None,
+ region: None,
+ country: None,
+ },
+ };
+ let rendered = toml::to_string_pretty(&document).expect("render draft");
+ assert!(rendered.contains("kind = \"listing_draft_v1\""));
+ }
+}
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -1,6 +1,7 @@
pub mod accounts;
pub mod config;
pub mod find;
+pub mod listing;
pub mod local;
pub mod logging;
pub mod myc;
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -0,0 +1,377 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use radroots_sql_core::{SqlExecutor, SqliteExecutor};
+use serde_json::{Value, json};
+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"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ ] {
+ command.env_remove(key);
+ }
+ command
+}
+
+#[test]
+fn listing_new_scaffolds_a_toml_draft_with_account_and_farm_defaults() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ let account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let seller_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey");
+ let account_id = account_json["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta");
+
+ let output = cli_command_in(dir.path())
+ .args(["--json", "listing", "new"])
+ .output()
+ .expect("run listing new");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "draft created");
+ assert_eq!(json["selected_account_id"], account_id);
+ assert_eq!(json["seller_pubkey"], seller_pubkey);
+ assert_eq!(json["farm_d_tag"], farm_d_tag);
+ let file = json["file"].as_str().expect("draft file");
+ let contents = fs::read_to_string(file).expect("draft contents");
+ assert!(contents.contains("kind = \"listing_draft_v1\""));
+ assert!(contents.contains(&format!("seller_pubkey = \"{seller_pubkey}\"")));
+ assert!(contents.contains(&format!("farm_d_tag = \"{farm_d_tag}\"")));
+}
+
+#[test]
+fn listing_validate_resolves_selected_account_and_matching_farm() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ let account_output = cli_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let seller_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("seller pubkey");
+ let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw";
+ seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta");
+
+ let draft_path = dir.path().join("eggs.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "",
+ "",
+ "eggs",
+ "Pasture eggs",
+ "Protein",
+ "Fresh pasture-raised eggs collected daily.",
+ "12",
+ "each",
+ "4.50",
+ "USD",
+ "1",
+ "each",
+ "18",
+ "pickup",
+ "La Huerta del Sur",
+ ),
+ )
+ .expect("write listing draft");
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "listing",
+ "validate",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing validate");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "valid");
+ assert_eq!(json["valid"], true);
+ assert_eq!(json["seller_pubkey"], seller_pubkey);
+ assert_eq!(json["farm_d_tag"], farm_d_tag);
+}
+
+#[test]
+fn listing_validate_reports_invalid_drafts_with_field_lines() {
+ let dir = tempdir().expect("tempdir");
+ let draft_path = dir.path().join("invalid.toml");
+ fs::write(
+ &draft_path,
+ valid_listing_draft(
+ "AAAAAAAAAAAAAAAAAAAAAg",
+ "AAAAAAAAAAAAAAAAAAAAAw",
+ &"b".repeat(64),
+ "eggs",
+ "Pasture eggs",
+ "Protein",
+ "Fresh pasture-raised eggs collected daily.",
+ "12",
+ "each",
+ "oops",
+ "USD",
+ "1",
+ "each",
+ "18",
+ "pickup",
+ "La Huerta del Sur",
+ ),
+ )
+ .expect("write invalid draft");
+
+ let output = cli_command_in(dir.path())
+ .args([
+ "--json",
+ "listing",
+ "validate",
+ draft_path.to_str().expect("draft path"),
+ ])
+ .output()
+ .expect("run listing validate");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "invalid");
+ assert_eq!(json["valid"], false);
+ assert_eq!(json["issues"][0]["field"], "primary_bin.price_amount");
+ assert!(json["issues"][0]["line"].as_u64().is_some());
+}
+
+#[test]
+fn listing_get_reads_real_local_rows_and_reports_missing() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000301",
+ "pasture-eggs",
+ "protein",
+ "Pasture Eggs",
+ "Fresh pasture-raised eggs collected daily.",
+ 36,
+ 18,
+ Some("Marshall"),
+ );
+
+ let json_output = cli_command_in(dir.path())
+ .args(["--json", "listing", "get", "pasture-eggs"])
+ .output()
+ .expect("run listing get");
+ assert!(json_output.status.success());
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["product_key"], "pasture-eggs");
+ assert_eq!(json["title"], "Pasture Eggs");
+ assert_eq!(json["location_primary"], "Marshall");
+ assert_eq!(json["provenance"]["origin"], "local_replica.trade_product");
+
+ let human_output = cli_command_in(dir.path())
+ .args(["listing", "get", "pasture-eggs"])
+ .output()
+ .expect("run human listing get");
+ assert!(human_output.status.success());
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("listing ·"));
+ assert!(stdout.contains("Pasture Eggs"));
+ assert!(stdout.contains("provenance: local replica"));
+
+ let missing_output = cli_command_in(dir.path())
+ .args(["--json", "listing", "get", "missing-listing"])
+ .output()
+ .expect("run missing listing get");
+ assert!(missing_output.status.success());
+ let missing_json: Value =
+ serde_json::from_slice(missing_output.stdout.as_slice()).expect("json");
+ assert_eq!(missing_json["state"], "missing");
+}
+
+fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) {
+ let replica_db = workdir
+ .join("home")
+ .join(".local/share/radroots/replica/replica.sqlite");
+ let executor = SqliteExecutor::open(&replica_db).expect("open replica db");
+ let now = "2026-04-07T00:00:00.000Z";
+ executor
+ .exec(
+ "INSERT INTO farm (id, created_at, updated_at, d_tag, pubkey, name, about, website, picture, banner, location_primary, location_city, location_region, location_country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ "11111111-1111-1111-1111-111111111111",
+ now,
+ now,
+ d_tag,
+ pubkey,
+ name,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert farm");
+}
+
+fn seed_trade_product(
+ workdir: &Path,
+ product_id: &str,
+ key: &str,
+ category: &str,
+ title: &str,
+ summary: &str,
+ qty_amt: i64,
+ qty_avail: i64,
+ location_label: Option<&str>,
+) {
+ let replica_db = workdir
+ .join("home")
+ .join(".local/share/radroots/replica/replica.sqlite");
+ let executor = SqliteExecutor::open(&replica_db).expect("open replica db");
+ let now = "2026-04-07T00:00:00.000Z";
+ executor
+ .exec(
+ "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ product_id,
+ now,
+ now,
+ key,
+ category,
+ title,
+ summary,
+ "fresh",
+ "lot-a",
+ "standard",
+ 2026,
+ qty_amt,
+ "each",
+ "dozen",
+ qty_avail,
+ 4.5,
+ "USD",
+ 1,
+ "each",
+ Value::Null
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert trade product");
+
+ if let Some(location_label) = location_label {
+ let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]);
+ executor
+ .exec(
+ "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ location_id,
+ now,
+ now,
+ format!("location-{product_id}"),
+ 35.0,
+ -82.0,
+ "dnrj",
+ "POINT(-82 35)",
+ "POLYGON EMPTY",
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ "USA"
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert gcs location");
+ executor
+ .exec(
+ "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);",
+ json!([product_id, location_id]).to_string().as_str(),
+ )
+ .expect("insert trade product location");
+ }
+}
+
+fn valid_listing_draft(
+ d_tag: &str,
+ farm_d_tag: &str,
+ seller_pubkey: &str,
+ key: &str,
+ title: &str,
+ category: &str,
+ summary: &str,
+ quantity_amount: &str,
+ quantity_unit: &str,
+ price_amount: &str,
+ price_currency: &str,
+ price_per_amount: &str,
+ price_per_unit: &str,
+ available: &str,
+ delivery_method: &str,
+ location_primary: &str,
+) -> String {
+ format!(
+ "version = 1\nkind = \"listing_draft_v1\"\n\n[listing]\nd_tag = \"{d_tag}\"\nfarm_d_tag = \"{farm_d_tag}\"\nseller_pubkey = \"{seller_pubkey}\"\n\n[product]\nkey = \"{key}\"\ntitle = \"{title}\"\ncategory = \"{category}\"\nsummary = \"{summary}\"\n\n[primary_bin]\nbin_id = \"bin-1\"\nquantity_amount = \"{quantity_amount}\"\nquantity_unit = \"{quantity_unit}\"\nprice_amount = \"{price_amount}\"\nprice_currency = \"{price_currency}\"\nprice_per_amount = \"{price_per_amount}\"\nprice_per_unit = \"{price_per_unit}\"\nlabel = \"dozen\"\n\n[inventory]\navailable = \"{available}\"\n\n[availability]\nkind = \"status\"\nstatus = \"active\"\n\n[delivery]\nmethod = \"{delivery_method}\"\n\n[location]\nprimary = \"{location_primary}\"\n"
+ )
+}