cli

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

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:
MCargo.lock | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 4++++
Msrc/cli.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Asrc/commands/listing.rs | 36++++++++++++++++++++++++++++++++++++
Msrc/commands/mod.rs | 9+++++----
Msrc/domain/runtime.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/runtime/listing.rs | 1045+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 1+
Atests/listing.rs | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, &params)?; + 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, &params)?; + 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" + ) +}