field_lib

Cross-platform Rust runtime for Radroots iOS and Android apps
git clone https://radroots.dev/git/field_lib.git
Log | Files | Refs | README | LICENSE

commit 2bb63789940a0f77ac8873449221ecb741b25460
parent 77091c1337316a246cb0cfcb9cabba36c6547b61
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 16:23:58 -0700

field: align runtime ffi with current rr-rs APIs

Diffstat:
Mcrates/field_core/src/runtime/key_management.rs | 8++++----
Mcrates/field_core/src/runtime/trade_listing.rs | 788+++++++++++++++++++------------------------------------------------------------
2 files changed, 189 insertions(+), 607 deletions(-)

diff --git a/crates/field_core/src/runtime/key_management.rs b/crates/field_core/src/runtime/key_management.rs @@ -13,7 +13,7 @@ impl RadrootsRuntime { if let Ok(guard) = self.net.lock() { return guard .accounts - .selected_signing_identity() + .default_signing_identity() .ok() .flatten() .is_some(); @@ -35,7 +35,7 @@ impl RadrootsRuntime { if let Ok(guard) = self.net.lock() { return guard .accounts - .selected_public_identity() + .default_public_identity() .ok() .flatten() .map(|identity| identity.public_key_npub); @@ -161,7 +161,7 @@ impl RadrootsRuntime { }; let Some(selected_id) = guard .accounts - .selected_account_id() + .default_account_id() .map_err(|e| RadrootsAppError::Msg(format!("{e}")))? else { return Ok(None); @@ -188,7 +188,7 @@ impl RadrootsRuntime { .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; guard .accounts - .select_account(&account_id) + .set_default_account(&account_id) .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; guard.nostr = None; Ok(()) diff --git a/crates/field_core/src/runtime/trade_listing.rs b/crates/field_core/src/runtime/trade_listing.rs @@ -7,35 +7,24 @@ use radroots_core::{ RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; use radroots_events::{ - RadrootsNostrEventPtr, + RadrootsNostrEvent, + farm::RadrootsFarmRef, + kinds::KIND_LISTING, listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, - RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, }, }; use radroots_events_codec::listing::encode::to_wire_parts as listing_to_wire_parts; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp, radroots_event_from_nostr, - radroots_nostr_parse_pubkey, -}; -use radroots_trade::listing::{ - dvm::TradeListingAddress, - dvm::{ - TradeListingEnvelope, TradeListingMessagePayload, TradeListingMessageType, - TradeListingValidateRequest, - }, - kinds::TRADE_LISTING_KINDS, - order::{TradeOrder, TradeOrderItem, TradeOrderStatus}, - tags::trade_listing_dvm_tags, - validation::{RadrootsTradeListing, validate_listing_event}, }; +use radroots_trade::listing::validation::validate_listing_event; use super::RadrootsRuntime; use crate::RadrootsAppError; -const LISTING_KIND: u32 = 30402; - #[derive(uniffi::Record, Debug, Clone)] pub struct TradeListingDraft { pub listing_id: Option<String>, @@ -82,6 +71,13 @@ pub struct TradeListingSummary { } #[derive(uniffi::Record, Debug, Clone)] +pub struct TradeListingEventParts { + pub kind: u32, + pub content: String, + pub tags_json: String, +} + +#[derive(uniffi::Record, Debug, Clone)] pub struct TradeOrderDraft { pub listing_addr: String, pub seller_pubkey: String, @@ -113,16 +109,33 @@ pub struct TradeListingMessageSummary { #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { + pub fn trade_listing_build_event_parts( + &self, + draft: TradeListingDraft, + ) -> Result<TradeListingEventParts, RadrootsAppError> { + let listing = listing_from_draft(&draft)?; + let parts = listing_to_wire_parts(&listing) + .map_err(|error| RadrootsAppError::Msg(format!("listing encode failed: {error}")))?; + let tags_json = serde_json::to_string(&parts.tags).map_err(|error| { + RadrootsAppError::Msg(format!("listing tags encode failed: {error}")) + })?; + Ok(TradeListingEventParts { + kind: parts.kind, + content: parts.content, + tags_json, + }) + } + pub fn trade_listing_publish( &self, draft: TradeListingDraft, ) -> Result<String, RadrootsAppError> { - let guard = self - .net - .lock() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; #[cfg(feature = "nostr-client")] { + let guard = self + .net + .lock() + .map_err(|error| RadrootsAppError::Msg(format!("{error}")))?; let mgr = guard .nostr .as_ref() @@ -131,16 +144,18 @@ impl RadrootsRuntime { let current_pubkey = current_pubkey_hex(self)?; if listing.farm.pubkey != current_pubkey { return Err(RadrootsAppError::Msg( - "farm_pubkey must match the active key".into(), + "farm_pubkey must match the default account public key".into(), )); } - let parts = listing_to_wire_parts(&listing) - .map_err(|e| RadrootsAppError::Msg(format!("listing encode failed: {e}")))?; + let parts = listing_to_wire_parts(&listing).map_err(|error| { + RadrootsAppError::Msg(format!("listing encode failed: {error}")) + })?; mgr.send_custom_event_blocking(parts.kind, parts.content, parts.tags) - .map_err(|e| RadrootsAppError::Msg(e.to_string())) + .map_err(|error| RadrootsAppError::Msg(error.to_string())) } #[cfg(not(feature = "nostr-client"))] { + let _ = draft; Err(RadrootsAppError::Msg("nostr disabled".into())) } } @@ -150,18 +165,18 @@ impl RadrootsRuntime { limit: u16, since_unix: Option<u64>, ) -> Result<Vec<TradeListingSummary>, RadrootsAppError> { - let guard = self - .net - .lock() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; #[cfg(feature = "nostr-client")] { + let guard = self + .net + .lock() + .map_err(|error| RadrootsAppError::Msg(format!("{error}")))?; let mgr = guard .nostr .as_ref() .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?; let mut filter = - RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom(LISTING_KIND as u16)); + RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom(KIND_LISTING as u16)); filter = filter.limit(limit.into()); if let Some(since) = since_unix { filter = filter.since(RadrootsNostrTimestamp::from(since)); @@ -169,25 +184,20 @@ impl RadrootsRuntime { let events = mgr .fetch_events_blocking(filter, core::time::Duration::from_secs(10)) - .map_err(|e| RadrootsAppError::Msg(e.to_string()))?; + .map_err(|error| RadrootsAppError::Msg(error.to_string()))?; let mut out = Vec::new(); - for ev in events { - let event = radroots_event_from_nostr(&ev); - if event.kind != LISTING_KIND { - continue; - } - match validate_listing_event(&event) { - Ok(listing) => { - out.push(listing_summary_from_trade(listing, &event)); - } - Err(_) => continue, + for event in events { + let event = radroots_event_from_nostr(&event); + if let Ok(listing) = validate_listing_event(&event) { + out.push(listing_summary_from_trade(listing, &event)); } } - out.sort_by(|a, b| b.published_at.cmp(&a.published_at)); + out.sort_by(|left, right| right.published_at.cmp(&left.published_at)); Ok(out) } #[cfg(not(feature = "nostr-client"))] { + let _ = (limit, since_unix); Err(RadrootsAppError::Msg("nostr disabled".into())) } } @@ -199,296 +209,94 @@ impl RadrootsRuntime { listing_id: String, recipient_pubkey: String, ) -> Result<String, RadrootsAppError> { - let listing_addr = listing_addr_from_parts(&seller_pubkey, &listing_id)?; - let payload = - TradeListingMessagePayload::ListingValidateRequest(TradeListingValidateRequest { - listing_event: Some(RadrootsNostrEventPtr { - id: listing_event_id, - relays: None, - }), - }); - self.send_trade_listing_message( - TradeListingMessageType::ListingValidateRequest, - listing_addr, - None, - payload, + let _ = ( + listing_event_id, + seller_pubkey, + listing_id, recipient_pubkey, - ) + ); + Err(RadrootsAppError::Msg( + "legacy listing validation requests are retired".into(), + )) } pub fn trade_listing_send_order_request( &self, draft: TradeOrderDraft, ) -> Result<TradeOrderSendResult, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let order_id = normalize_optional_id(draft.order_id); - let order_id = order_id - .unwrap_or_else(|| format!("order-{}", chrono::Utc::now().timestamp_millis())); - let buyer_pubkey = current_pubkey_hex(self)?; - let seller_pubkey = normalize_pubkey(&draft.seller_pubkey)?; - - let bin_id = draft.bin_id.trim(); - if bin_id.is_empty() { - return Err(RadrootsAppError::Msg("bin_id is required".into())); - } - let bin_count = parse_u32(&draft.bin_count, "bin_count")?; - if bin_count == 0 { - return Err(RadrootsAppError::Msg("bin_count must be > 0".into())); - } - - let item = TradeOrderItem { - bin_id: bin_id.to_string(), - bin_count, - }; - - let notes = draft - .notes - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - let order = TradeOrder { - order_id: order_id.clone(), - listing_addr: draft.listing_addr.clone(), - buyer_pubkey, - seller_pubkey, - items: vec![item], - discounts: None, - notes, - status: TradeOrderStatus::Requested, - }; - - let payload = TradeListingMessagePayload::OrderRequest(order); - let event_id = self.send_trade_listing_message( - TradeListingMessageType::OrderRequest, - draft.listing_addr, - Some(order_id.clone()), - payload, - draft.recipient_pubkey, - )?; - - Ok(TradeOrderSendResult { event_id, order_id }) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::Msg("nostr disabled".into())) - } + let _ = draft; + Err(RadrootsAppError::Msg( + "legacy listing order requests are retired; use active trade order APIs".into(), + )) } pub fn trade_listing_fetch_messages( &self, listing_addr: String, - order_id: Option<String>, limit: u16, since_unix: Option<u64>, ) -> Result<Vec<TradeListingMessageSummary>, RadrootsAppError> { - let guard = self - .net - .lock() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - #[cfg(feature = "nostr-client")] - { - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?; - - let kinds: Vec<RadrootsNostrKind> = TRADE_LISTING_KINDS - .iter() - .map(|k| RadrootsNostrKind::Custom(*k)) - .collect(); - - let mut filter = RadrootsNostrFilter::new().kinds(kinds); - filter = filter.limit(limit.into()); - if let Some(since) = since_unix { - filter = filter.since(RadrootsNostrTimestamp::from(since)); - } - - let events = mgr - .fetch_events_blocking(filter, core::time::Duration::from_secs(10)) - .map_err(|e| RadrootsAppError::Msg(e.to_string()))?; - - let mut out = Vec::new(); - for ev in events { - let content = ev.content.clone(); - let envelope: TradeListingEnvelope<TradeListingMessagePayload> = - match serde_json::from_str(&content) { - Ok(env) => env, - Err(_) => continue, - }; - if envelope.validate().is_err() { - continue; - } - if envelope.listing_addr != listing_addr { - continue; - } - if let Some(ref oid) = order_id { - if envelope.order_id.as_deref() != Some(oid) { - continue; - } - } - let kind_u32 = ev.kind.as_u16() as u32; - if envelope.message_type.kind() as u32 != kind_u32 { - continue; - } - - let summary = message_summary(&envelope.payload); - out.push(TradeListingMessageSummary { - event_id: ev.id.to_string(), - author: ev.pubkey.to_string(), - published_at: ev.created_at.as_secs(), - kind: kind_u32, - message_type: message_type_label(envelope.message_type).to_string(), - listing_addr: envelope.listing_addr.clone(), - order_id: envelope.order_id.clone(), - summary, - payload_json: content, - }); - } - out.sort_by(|a, b| b.published_at.cmp(&a.published_at)); - Ok(out) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::Msg("nostr disabled".into())) - } - } -} - -impl RadrootsRuntime { - fn send_trade_listing_message( - &self, - message_type: TradeListingMessageType, - listing_addr: String, - order_id: Option<String>, - payload: TradeListingMessagePayload, - recipient_pubkey: String, - ) -> Result<String, RadrootsAppError> { - let guard = self - .net - .lock() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - #[cfg(feature = "nostr-client")] - { - let mgr = guard - .nostr - .as_ref() - .ok_or_else(|| RadrootsAppError::Msg("nostr not initialized".into()))?; - let recipient_hex = normalize_pubkey(&recipient_pubkey)?; - let envelope = TradeListingEnvelope::new( - message_type, - listing_addr.clone(), - order_id.clone(), - payload, - ); - envelope - .validate() - .map_err(|e| RadrootsAppError::Msg(e.to_string()))?; - let content = serde_json::to_string(&envelope) - .map_err(|e| RadrootsAppError::Msg(format!("encode envelope failed: {e}")))?; - let tags = trade_listing_dvm_tags(recipient_hex, listing_addr, order_id); - mgr.send_custom_event_blocking(message_type.kind() as u32, content, tags) - .map_err(|e| RadrootsAppError::Msg(e.to_string())) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::Msg("nostr disabled".into())) - } + let _ = (listing_addr, limit, since_unix); + Ok(Vec::new()) } } fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, RadrootsAppError> { - let listing_id = draft - .listing_id - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("listing-{}", chrono::Utc::now().timestamp_millis())); - let farm_pubkey = draft.farm_pubkey.trim(); - if farm_pubkey.is_empty() { - return Err(RadrootsAppError::Msg("farm_pubkey is required".into())); - } - let farm_pubkey = normalize_pubkey(farm_pubkey)?; - let farm_d_tag = draft.farm_d_tag.trim(); - if farm_d_tag.is_empty() { - return Err(RadrootsAppError::Msg("farm_d_tag is required".into())); - } - - let title = draft.title.trim(); - if title.is_empty() { - return Err(RadrootsAppError::Msg("title is required".into())); - } - let description = draft.description.trim(); - if description.is_empty() { - return Err(RadrootsAppError::Msg("description is required".into())); - } - let category = draft.category.trim(); - if category.is_empty() { - return Err(RadrootsAppError::Msg("category is required".into())); - } - let location_primary = draft.location_primary.trim(); - if location_primary.is_empty() { - return Err(RadrootsAppError::Msg("location is required".into())); - } - - let display_amount = parse_decimal(&draft.bin_display_amount, "bin_display_amount")?; - ensure_non_negative(&display_amount, "bin_display_amount")?; - let display_unit = parse_unit(&draft.bin_display_unit)?; - let unit_price_amount = parse_decimal(&draft.unit_price, "unit_price")?; - ensure_non_negative(&unit_price_amount, "unit_price")?; + let listing_id = non_empty( + draft + .listing_id + .clone() + .unwrap_or_else(|| format!("listing-{}", chrono::Utc::now().timestamp_millis())), + "listing_id", + )?; + let farm_pubkey = non_empty(draft.farm_pubkey.clone(), "farm_pubkey")?; + let farm_d_tag = non_empty(draft.farm_d_tag.clone(), "farm_d_tag")?; + let title = non_empty(draft.title.clone(), "title")?; + let description = non_empty(draft.description.clone(), "description")?; + let category = non_empty(draft.category.clone(), "category")?; + let bin_id = non_empty( + draft.bin_id.clone().unwrap_or_else(|| "bin-1".to_string()), + "bin_id", + )?; + let amount = parse_decimal(&draft.bin_display_amount, "bin_display_amount")?; + let unit = parse_unit(&draft.bin_display_unit)?; + let canonical_unit = unit.canonical_unit(); let currency = parse_currency(&draft.currency)?; + let unit_price = parse_decimal(&draft.unit_price, "unit_price")?; let inventory = parse_decimal(&draft.inventory, "inventory")?; - ensure_non_negative(&inventory, "inventory")?; - - let display_quantity = RadrootsCoreQuantity::new(display_amount, display_unit); - let canonical_quantity = display_quantity - .to_canonical() - .map_err(|e| RadrootsAppError::Msg(format!("invalid bin_display_unit: {e}")))?; - let unit_price = RadrootsCoreMoney::new(unit_price_amount, currency); - let price_per_display_unit = RadrootsCoreQuantityPrice::new( - unit_price.clone(), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, display_unit), - ); - let price_per_canonical_unit = price_per_display_unit - .try_to_canonical_unit_price() - .map_err(|e| RadrootsAppError::Msg(format!("invalid unit_price: {e:?}")))?; - let bin_label = clean_optional(&draft.bin_label); - let bin_id = normalize_optional_id(draft.bin_id.clone()).unwrap_or_else(|| "bin-1".to_string()); - let bin = RadrootsListingBin { - bin_id: bin_id.clone(), - quantity: canonical_quantity, - price_per_canonical_unit, - display_amount: Some(display_amount), - display_unit: Some(display_unit), - display_label: bin_label, - display_price: Some(unit_price), - display_price_unit: Some(display_unit), - }; - - let delivery_method = parse_delivery_method(&draft.delivery_method)?; + let location_primary = non_empty(draft.location_primary.clone(), "location_primary")?; Ok(RadrootsListing { d_tag: listing_id, - farm: RadrootsListingFarmRef { + farm: RadrootsFarmRef { pubkey: farm_pubkey, - d_tag: farm_d_tag.to_string(), + d_tag: farm_d_tag, }, product: RadrootsListingProduct { - key: category.to_string(), - title: title.to_string(), - category: category.to_string(), - summary: Some(description.to_string()), + key: category.clone(), + title, + category, + summary: Some(description), process: None, lot: None, location: None, profile: None, year: None, }, - primary_bin_id: bin_id, - bins: vec![bin], + primary_bin_id: bin_id.clone(), + bins: vec![RadrootsListingBin { + bin_id, + quantity: RadrootsCoreQuantity::new(amount, canonical_unit), + price_per_canonical_unit: RadrootsCoreQuantityPrice::new( + RadrootsCoreMoney::new(unit_price, currency), + RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, canonical_unit), + ), + display_amount: Some(amount), + display_unit: Some(unit), + display_label: draft.bin_label.clone(), + display_price: Some(RadrootsCoreMoney::new(unit_price, currency)), + display_price_unit: Some(unit), + }], resource_area: None, plot: None, discounts: None, @@ -496,12 +304,12 @@ fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, Radr availability: Some(RadrootsListingAvailability::Status { status: RadrootsListingStatus::Active, }), - delivery_method: Some(delivery_method), + delivery_method: Some(parse_delivery_method(&draft.delivery_method)), location: Some(RadrootsListingLocation { - primary: location_primary.to_string(), - city: clean_optional(&draft.location_city), - region: clean_optional(&draft.location_region), - country: clean_optional(&draft.location_country), + primary: location_primary, + city: blank_to_none(draft.location_city.clone()), + region: blank_to_none(draft.location_region.clone()), + country: blank_to_none(draft.location_country.clone()), lat: None, lng: None, geohash: None, @@ -511,46 +319,17 @@ fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, Radr } fn listing_summary_from_trade( - listing: RadrootsTradeListing, - event: &radroots_events::RadrootsNostrEvent, + listing: radroots_trade::listing::validation::RadrootsTradeListing, + event: &RadrootsNostrEvent, ) -> TradeListingSummary { - let bin = listing + let primary_bin = listing .listing .bins .iter() - .find(|bin| bin.bin_id == listing.primary_bin_id) - .or_else(|| listing.listing.bins.first()) - .expect("validated listing must include bins"); - let (display_amount, display_unit) = match (bin.display_amount.as_ref(), bin.display_unit) { - (Some(amount), Some(unit)) => (amount.clone(), unit), - _ => (bin.quantity.amount.clone(), bin.quantity.unit), - }; - let display_label = bin.display_label.clone().or(bin.quantity.label.clone()); - let display_label = clean_optional(&display_label); - let (unit_price_amount, unit_price_currency, unit_price_unit) = - match bin.price_per_canonical_unit.try_to_unit_price(display_unit) { - Ok(price) => ( - price.amount.amount.to_string(), - price.amount.currency.to_string(), - price.quantity.unit.to_string(), - ), - Err(_) => match (&bin.display_price, bin.display_price_unit) { - (Some(price), Some(unit)) => ( - price.amount.to_string(), - price.currency.to_string(), - unit.to_string(), - ), - _ => ( - bin.price_per_canonical_unit.amount.amount.to_string(), - bin.price_per_canonical_unit.amount.currency.to_string(), - bin.price_per_canonical_unit.quantity.unit.to_string(), - ), - }, - }; - + .find(|bin| bin.bin_id == listing.primary_bin_id); TradeListingSummary { event_id: event.id.clone(), - seller_pubkey: event.author.clone(), + seller_pubkey: listing.seller_pubkey, published_at: event.created_at as u64, listing_id: listing.listing_id, listing_addr: listing.listing_addr, @@ -558,47 +337,23 @@ fn listing_summary_from_trade( description: listing.description, product_type: listing.product_type, primary_bin_id: listing.primary_bin_id, - unit_price_amount, - unit_price_currency, - unit_price_unit, - bin_display_amount: display_amount.to_string(), - bin_display_unit: display_unit.to_string(), - bin_display_label: display_label, + unit_price_amount: listing.unit_price.amount.to_string(), + unit_price_currency: listing.unit_price.currency.to_string(), + unit_price_unit: listing.unit.to_string(), + bin_display_amount: primary_bin + .and_then(|bin| bin.display_amount) + .unwrap_or(listing.bin_quantity.amount) + .to_string(), + bin_display_unit: primary_bin + .and_then(|bin| bin.display_unit) + .unwrap_or(listing.unit) + .to_string(), + bin_display_label: primary_bin.and_then(|bin| bin.display_label.clone()), inventory_available: listing.inventory_available.to_string(), availability: availability_label(&listing.availability), - location: format_location(&listing.location), - delivery_method: delivery_method_label(&listing.delivery_method).to_string(), - } -} - -fn listing_addr_from_parts( - seller_pubkey: &str, - listing_id: &str, -) -> Result<String, RadrootsAppError> { - let listing_id = listing_id.trim(); - if listing_id.is_empty() { - return Err(RadrootsAppError::Msg("listing_id is required".into())); - } - let seller_hex = normalize_pubkey(seller_pubkey)?; - Ok(TradeListingAddress { - kind: LISTING_KIND as u16, - seller_pubkey: seller_hex, - listing_id: listing_id.to_string(), + location: location_label(&listing.location), + delivery_method: delivery_method_label(&listing.delivery_method), } - .as_str()) -} - -fn normalize_pubkey(pubkey: &str) -> Result<String, RadrootsAppError> { - let key = radroots_nostr_parse_pubkey(pubkey.trim()) - .map_err(|e| RadrootsAppError::Msg(e.to_string()))?; - Ok(key.to_hex()) -} - -fn normalize_optional_id(id: Option<String>) -> Option<String> { - id.as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) } #[cfg(feature = "nostr-client")] @@ -606,260 +361,87 @@ fn current_pubkey_hex(runtime: &RadrootsRuntime) -> Result<String, RadrootsAppEr let guard = runtime .net .lock() - .map_err(|e| RadrootsAppError::Msg(format!("{e}")))?; - let keys = guard - .selected_nostr_keys() - .ok_or_else(|| RadrootsAppError::Msg("no selected signing identity".into()))?; - Ok(keys.public_key().to_hex()) + .map_err(|error| RadrootsAppError::Msg(format!("{error}")))?; + let identity = guard + .accounts + .default_public_identity() + .map_err(|error| RadrootsAppError::Msg(format!("{error}")))? + .ok_or_else(|| RadrootsAppError::Msg("default account is not configured".into()))?; + Ok(identity.public_key_hex) } -fn parse_decimal(value: &str, label: &str) -> Result<RadrootsCoreDecimal, RadrootsAppError> { - RadrootsCoreDecimal::from_str(value.trim()) - .map_err(|e| RadrootsAppError::Msg(format!("invalid {label}: {e}"))) +fn non_empty(value: String, field: &str) -> Result<String, RadrootsAppError> { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(RadrootsAppError::Msg(format!("{field} is required"))); + } + Ok(value) } -fn parse_unit(value: &str) -> Result<RadrootsCoreUnit, RadrootsAppError> { - RadrootsCoreUnit::from_str(value.trim()) - .map_err(|e| RadrootsAppError::Msg(format!("invalid unit: {e}"))) +fn blank_to_none(value: Option<String>) -> Option<String> { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } -fn parse_currency(value: &str) -> Result<RadrootsCoreCurrency, RadrootsAppError> { - RadrootsCoreCurrency::from_str(value.trim()) - .map_err(|e| RadrootsAppError::Msg(format!("invalid currency: {e}"))) +fn parse_decimal(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RadrootsAppError> { + RadrootsCoreDecimal::from_str(value.trim()) + .map_err(|error| RadrootsAppError::Msg(format!("{field} is invalid: {error}"))) } -fn parse_u32(value: &str, label: &str) -> Result<u32, RadrootsAppError> { - value - .trim() - .parse::<u32>() - .map_err(|e| RadrootsAppError::Msg(format!("invalid {label}: {e}"))) +fn parse_currency(value: &str) -> Result<RadrootsCoreCurrency, RadrootsAppError> { + RadrootsCoreCurrency::from_str(value.trim()) + .map_err(|error| RadrootsAppError::Msg(format!("currency is invalid: {error}"))) } -fn ensure_non_negative(value: &RadrootsCoreDecimal, label: &str) -> Result<(), RadrootsAppError> { - if value.is_sign_negative() { - return Err(RadrootsAppError::Msg(format!( - "{label} must be non-negative" - ))); - } - Ok(()) +fn parse_unit(value: &str) -> Result<RadrootsCoreUnit, RadrootsAppError> { + RadrootsCoreUnit::from_str(value.trim()) + .map_err(|error| RadrootsAppError::Msg(format!("unit is invalid: {error}"))) } -fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RadrootsAppError> { - let raw = value.trim(); - if raw.is_empty() { - return Err(RadrootsAppError::Msg("delivery_method is required".into())); - } - let lowered = raw.to_ascii_lowercase(); - Ok(match lowered.as_str() { +fn parse_delivery_method(value: &str) -> RadrootsListingDeliveryMethod { + match value.trim().to_ascii_lowercase().as_str() { "pickup" => RadrootsListingDeliveryMethod::Pickup, "local_delivery" | "local delivery" => RadrootsListingDeliveryMethod::LocalDelivery, "shipping" => RadrootsListingDeliveryMethod::Shipping, - _ => RadrootsListingDeliveryMethod::Other { - method: raw.to_string(), + other => RadrootsListingDeliveryMethod::Other { + method: other.to_string(), }, - }) + } } -fn availability_label(availability: &RadrootsListingAvailability) -> String { - match availability { +fn availability_label(value: &RadrootsListingAvailability) -> String { + match value { + RadrootsListingAvailability::Window { start, end } => { + format!("window:{start:?}:{end:?}") + } RadrootsListingAvailability::Status { status } => match status { RadrootsListingStatus::Active => "active".to_string(), RadrootsListingStatus::Sold => "sold".to_string(), RadrootsListingStatus::Other { value } => value.clone(), }, - RadrootsListingAvailability::Window { start, end } => { - let start = start - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".into()); - let end = end - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".into()); - format!("{start} - {end}") - } - } -} - -fn delivery_method_label(method: &RadrootsListingDeliveryMethod) -> &'static str { - match method { - RadrootsListingDeliveryMethod::Pickup => "pickup", - RadrootsListingDeliveryMethod::LocalDelivery => "local delivery", - RadrootsListingDeliveryMethod::Shipping => "shipping", - RadrootsListingDeliveryMethod::Other { .. } => "other", - } -} - -fn format_location(location: &RadrootsListingLocation) -> String { - let mut parts = Vec::with_capacity(4); - if !location.primary.trim().is_empty() { - parts.push(location.primary.trim()); - } - if let Some(city) = location.city.as_deref() { - if !city.trim().is_empty() { - parts.push(city.trim()); - } - } - if let Some(region) = location.region.as_deref() { - if !region.trim().is_empty() { - parts.push(region.trim()); - } - } - if let Some(country) = location.country.as_deref() { - if !country.trim().is_empty() { - parts.push(country.trim()); - } - } - if parts.is_empty() { - "n/a".to_string() - } else { - parts.join(", ") - } -} - -fn clean_optional(value: &Option<String>) -> Option<String> { - value - .as_deref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -fn message_type_label(message_type: TradeListingMessageType) -> &'static str { - match message_type { - TradeListingMessageType::ListingValidateRequest => "listing_validate_request", - TradeListingMessageType::ListingValidateResult => "listing_validate_result", - TradeListingMessageType::OrderRequest => "order_request", - TradeListingMessageType::OrderResponse => "order_response", - TradeListingMessageType::OrderRevision => "order_revision", - TradeListingMessageType::OrderRevisionAccept => "order_revision_accept", - TradeListingMessageType::OrderRevisionDecline => "order_revision_decline", - TradeListingMessageType::Question => "question", - TradeListingMessageType::Answer => "answer", - TradeListingMessageType::DiscountRequest => "discount_request", - TradeListingMessageType::DiscountOffer => "discount_offer", - TradeListingMessageType::DiscountAccept => "discount_accept", - TradeListingMessageType::DiscountDecline => "discount_decline", - TradeListingMessageType::Cancel => "cancel", - TradeListingMessageType::FulfillmentUpdate => "fulfillment_update", - TradeListingMessageType::Receipt => "receipt", } } -fn message_summary(payload: &TradeListingMessagePayload) -> String { - match payload { - TradeListingMessagePayload::ListingValidateRequest(_) => { - "Listing validation requested".to_string() - } - TradeListingMessagePayload::ListingValidateResult(result) => { - if result.valid { - "Listing validated".to_string() - } else if let Some(first) = result.errors.first() { - format!("Listing invalid: {first}") - } else { - "Listing invalid".to_string() - } - } - TradeListingMessagePayload::OrderRequest(order) => { - let item = order.items.first(); - match item { - Some(i) => format!("Order requested: {}x {}", i.bin_count, i.bin_id), - None => "Order requested".to_string(), - } - } - TradeListingMessagePayload::OrderResponse(res) => { - if res.accepted { - "Order accepted".to_string() - } else if let Some(reason) = res.reason.as_deref() { - format!("Order declined: {reason}") - } else { - "Order declined".to_string() - } - } - TradeListingMessagePayload::OrderRevision(_) => "Order revision proposed".to_string(), - TradeListingMessagePayload::OrderRevisionAccept(_) => "Order revision accepted".to_string(), - TradeListingMessagePayload::OrderRevisionDecline(_) => { - "Order revision declined".to_string() - } - TradeListingMessagePayload::Question(q) => format!("Question: {}", q.question_text), - TradeListingMessagePayload::Answer(a) => format!("Answer: {}", a.answer_text), - TradeListingMessagePayload::DiscountRequest(_) => "Discount requested".to_string(), - TradeListingMessagePayload::DiscountOffer(_) => "Discount offered".to_string(), - TradeListingMessagePayload::DiscountAccept(_) => "Discount accepted".to_string(), - TradeListingMessagePayload::DiscountDecline(_) => "Discount declined".to_string(), - TradeListingMessagePayload::Cancel(c) => { - if let Some(reason) = c.reason.as_deref() { - format!("Order cancelled: {reason}") - } else { - "Order cancelled".to_string() - } - } - TradeListingMessagePayload::FulfillmentUpdate(update) => { - format!("Fulfillment update: {:?}", update.status) - } - TradeListingMessagePayload::Receipt(receipt) => { - if receipt.acknowledged { - "Receipt acknowledged".to_string() - } else { - "Receipt update".to_string() - } - } +fn delivery_method_label(value: &RadrootsListingDeliveryMethod) -> String { + match value { + RadrootsListingDeliveryMethod::Pickup => "pickup".to_string(), + RadrootsListingDeliveryMethod::LocalDelivery => "local_delivery".to_string(), + RadrootsListingDeliveryMethod::Shipping => "shipping".to_string(), + RadrootsListingDeliveryMethod::Other { method } => method.clone(), } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn listing_from_draft_requires_fields() { - let draft = TradeListingDraft { - listing_id: None, - farm_pubkey: "".into(), - farm_d_tag: "".into(), - title: "".into(), - description: "Desc".into(), - category: "Coffee".into(), - bin_display_amount: "1".into(), - bin_display_unit: "lb".into(), - unit_price: "10.00".into(), - currency: "USD".into(), - bin_label: None, - bin_id: None, - inventory: "10".into(), - delivery_method: "shipping".into(), - location_primary: "Farm".into(), - location_city: None, - location_region: None, - location_country: None, - }; - assert!(listing_from_draft(&draft).is_err()); - } - - #[test] - fn listing_from_draft_builds_listing() { - let draft = TradeListingDraft { - listing_id: Some("AAAAAAAAAAAAAAAAAAAAAg".into()), - farm_pubkey: "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6".into(), - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - title: "Coffee".into(), - description: "Washed".into(), - category: "coffee".into(), - bin_display_amount: "1".into(), - bin_display_unit: "lb".into(), - unit_price: "12.50".into(), - currency: "USD".into(), - bin_label: Some("bag".into()), - bin_id: None, - inventory: "5".into(), - delivery_method: "shipping".into(), - location_primary: "Farm".into(), - location_city: Some("Town".into()), - location_region: Some("Region".into()), - location_country: Some("US".into()), - }; - let listing = listing_from_draft(&draft).expect("listing builds"); - assert_eq!(listing.d_tag, "AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(listing.product.title, "Coffee"); - assert!(listing.delivery_method.is_some()); - assert!(listing.location.is_some()); - } +fn location_label(value: &RadrootsListingLocation) -> String { + [ + Some(value.primary.as_str()), + value.city.as_deref(), + value.region.as_deref(), + value.country.as_deref(), + ] + .into_iter() + .flatten() + .filter(|part| !part.trim().is_empty()) + .collect::<Vec<_>>() + .join(", ") }