cli

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

listing.rs (121224B)


      1 use std::collections::HashMap;
      2 use std::fs;
      3 use std::path::{Path, PathBuf};
      4 use std::sync::atomic::{AtomicU64, Ordering};
      5 use std::time::{SystemTime, UNIX_EPOCH};
      6 
      7 use radroots_authority::RadrootsActorContext;
      8 use radroots_core::{
      9     RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope,
     10     RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
     11     RadrootsCorePercent, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
     12 };
     13 use radroots_events::RadrootsNostrEvent;
     14 use radroots_events::contract::RadrootsActorRole;
     15 use radroots_events::farm::RadrootsFarmRef;
     16 use radroots_events::ids::{RadrootsDTag, RadrootsInventoryBinId};
     17 use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT};
     18 use radroots_events::listing::{
     19     RadrootsListing, RadrootsListingAvailability, RadrootsListingBin,
     20     RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct,
     21     RadrootsListingStatus,
     22 };
     23 use radroots_events::trade_validation::RadrootsTradeValidationListingError;
     24 use radroots_events_codec::d_tag::is_d_tag_base64url;
     25 use radroots_events_codec::listing::encode::to_wire_parts_with_kind;
     26 use radroots_local_events::{LocalEventRecord, LocalRecordFamily, SourceRuntime};
     27 use radroots_replica_db::ReplicaSql;
     28 use radroots_sdk::{
     29     ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest,
     30     ListingPublishPlan, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt,
     31     PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState,
     32 };
     33 use radroots_sql_core::SqliteExecutor;
     34 use radroots_trade::listing::{RadrootsListingDraftDocumentV1, validation::validate_listing_event};
     35 use serde::{Deserialize, Serialize};
     36 use serde_json::{Value, json};
     37 
     38 use crate::cli::global::{
     39     ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs,
     40     ListingRebindArgs, RecordLookupArgs,
     41 };
     42 use crate::runtime::RuntimeError;
     43 use crate::runtime::account;
     44 use crate::runtime::config::RuntimeConfig;
     45 use crate::runtime::farm_config;
     46 use crate::runtime::local_events::{
     47     append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest,
     48     shared_local_events_db_path,
     49 };
     50 use crate::runtime::sdk::{
     51     CliSdkAdapterError, CliSdkSession, sdk_relay_target_policy, sdk_relay_url_policy,
     52     validate_configured_signer_for_actor,
     53 };
     54 use crate::runtime::sync::{
     55     RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness,
     56 };
     57 use crate::view::runtime::{
     58     FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView,
     59     ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView,
     60     ListingMutationEventView, ListingMutationView, ListingNewView, ListingRebindView,
     61     ListingSummaryView, ListingValidateView, ListingValidationIssueView, MarketReadinessView,
     62     RelayFailureView,
     63 };
     64 
     65 const DRAFT_KIND: &str = "listing_draft_v1";
     66 const LISTING_SOURCE: &str = "local draft · local first";
     67 const LISTING_READ_SOURCE: &str = "local replica · local first";
     68 const LISTING_APP_RECORD_SOURCE: &str = "shared local events · app";
     69 const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · configured signer";
     70 const LISTING_DRAFTS_DIR: &str = "listings/drafts";
     71 const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config";
     72 const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account";
     73 const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind";
     74 const CANONICAL_OWNER_PUBKEY_REQUIRED_REASON: &str = "canonical hex pubkey required before export";
     75 const APP_RECORD_LIST_LIMIT: u32 = 500;
     76 
     77 static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0);
     78 
     79 fn protocol_d_tag(value: &str, field: &str) -> Result<RadrootsDTag, RuntimeError> {
     80     value
     81         .parse()
     82         .map_err(|error| RuntimeError::Config(format!("{field} is not a valid d tag: {error}")))
     83 }
     84 
     85 fn protocol_inventory_bin_id(
     86     value: &str,
     87     field: &str,
     88 ) -> Result<RadrootsInventoryBinId, RuntimeError> {
     89     value.parse().map_err(|error| {
     90         RuntimeError::Config(format!("{field} is not a valid inventory bin id: {error}"))
     91     })
     92 }
     93 
     94 #[derive(Debug, Clone, Serialize, Deserialize)]
     95 #[serde(deny_unknown_fields)]
     96 struct ListingDraftDocument {
     97     version: u32,
     98     kind: String,
     99     listing: ListingDraftMeta,
    100     seller_actor: ListingDraftSellerActor,
    101     product: ListingDraftProduct,
    102     primary_bin: ListingDraftPrimaryBin,
    103     inventory: ListingDraftInventory,
    104     availability: ListingDraftAvailability,
    105     delivery: ListingDraftDelivery,
    106     location: ListingDraftLocation,
    107     #[serde(default, skip_serializing_if = "Vec::is_empty")]
    108     discounts: Vec<ListingDraftDiscount>,
    109 }
    110 
    111 #[derive(Debug, Clone, Serialize, Deserialize)]
    112 #[serde(deny_unknown_fields)]
    113 struct ListingDraftMeta {
    114     d_tag: String,
    115     farm_d_tag: String,
    116 }
    117 
    118 #[derive(Debug, Clone, Serialize, Deserialize)]
    119 #[serde(deny_unknown_fields)]
    120 struct ListingDraftSellerActor {
    121     account_id: String,
    122     pubkey: String,
    123     source: String,
    124 }
    125 
    126 #[derive(Debug, Clone, Serialize, Deserialize)]
    127 #[serde(deny_unknown_fields)]
    128 struct ListingDraftProduct {
    129     key: String,
    130     title: String,
    131     category: String,
    132     summary: String,
    133 }
    134 
    135 #[derive(Debug, Clone, Serialize, Deserialize)]
    136 #[serde(deny_unknown_fields)]
    137 struct ListingDraftPrimaryBin {
    138     bin_id: String,
    139     quantity_amount: String,
    140     quantity_unit: String,
    141     price_amount: String,
    142     price_currency: String,
    143     price_per_amount: String,
    144     price_per_unit: String,
    145     #[serde(default, skip_serializing_if = "String::is_empty")]
    146     label: String,
    147 }
    148 
    149 #[derive(Debug, Clone, Serialize, Deserialize)]
    150 #[serde(deny_unknown_fields)]
    151 struct ListingDraftInventory {
    152     available: String,
    153 }
    154 
    155 #[derive(Debug, Clone, Serialize, Deserialize)]
    156 #[serde(deny_unknown_fields)]
    157 struct ListingDraftAvailability {
    158     #[serde(default, skip_serializing_if = "String::is_empty")]
    159     kind: String,
    160     #[serde(default, skip_serializing_if = "String::is_empty")]
    161     status: String,
    162     #[serde(default, skip_serializing_if = "Option::is_none")]
    163     start: Option<u64>,
    164     #[serde(default, skip_serializing_if = "Option::is_none")]
    165     end: Option<u64>,
    166 }
    167 
    168 #[derive(Debug, Clone, Serialize, Deserialize)]
    169 #[serde(deny_unknown_fields)]
    170 struct ListingDraftDelivery {
    171     method: String,
    172 }
    173 
    174 #[derive(Debug, Clone, Serialize, Deserialize)]
    175 #[serde(deny_unknown_fields)]
    176 struct ListingDraftLocation {
    177     primary: String,
    178     #[serde(default, skip_serializing_if = "Option::is_none")]
    179     city: Option<String>,
    180     #[serde(default, skip_serializing_if = "Option::is_none")]
    181     region: Option<String>,
    182     #[serde(default, skip_serializing_if = "Option::is_none")]
    183     country: Option<String>,
    184 }
    185 
    186 #[derive(Debug, Clone, Serialize, Deserialize)]
    187 #[serde(deny_unknown_fields)]
    188 struct ListingDraftDiscount {
    189     id: String,
    190     #[serde(default, skip_serializing_if = "String::is_empty")]
    191     label: String,
    192     kind: String,
    193     #[serde(default, skip_serializing_if = "String::is_empty")]
    194     value: String,
    195     #[serde(default, skip_serializing_if = "String::is_empty")]
    196     amount: String,
    197     #[serde(default, skip_serializing_if = "String::is_empty")]
    198     currency: String,
    199     #[serde(default, skip_serializing_if = "Option::is_none")]
    200     bin_id: Option<String>,
    201     #[serde(default, skip_serializing_if = "Option::is_none")]
    202     min_bin_count: Option<u32>,
    203 }
    204 
    205 #[derive(Debug, Clone)]
    206 struct ListingValidationContext {
    207     farm_setup_action: String,
    208 }
    209 
    210 #[derive(Debug, Clone)]
    211 struct ListingAuthoringDefaults {
    212     farm_config_present: bool,
    213     farm_defaults_ready: bool,
    214     farm_next_action: Option<String>,
    215     farm_reason: Option<String>,
    216     farm_name: Option<String>,
    217     seller_account_id: String,
    218     seller_pubkey: String,
    219     seller_actor_source: String,
    220     selected_farm_d_tag: Option<String>,
    221     delivery_method: Option<String>,
    222     location: Option<ListingDraftLocation>,
    223 }
    224 
    225 #[derive(Debug, Clone)]
    226 struct CanonicalListingDraft {
    227     listing_id: String,
    228     seller_account_id: String,
    229     seller_pubkey: String,
    230     seller_actor_source: String,
    231     farm_d_tag: String,
    232     listing: RadrootsListing,
    233 }
    234 
    235 #[derive(Debug, Clone)]
    236 struct SdkListingPublishInput {
    237     canonical: CanonicalListingDraft,
    238     actor: RadrootsActorContext,
    239     document: RadrootsListingDraftDocumentV1,
    240 }
    241 
    242 #[derive(Debug, Clone)]
    243 struct LoadedListingDraft {
    244     file: PathBuf,
    245     updated_at_unix: u64,
    246     contents: String,
    247     document: ListingDraftDocument,
    248 }
    249 
    250 #[derive(Debug, Clone)]
    251 enum ListingDraftValidationError {
    252     Issue(ListingValidationIssueView),
    253     MissingSellerAccount(ListingValidationIssueView),
    254 }
    255 
    256 impl ListingDraftValidationError {
    257     fn into_issue(self) -> ListingValidationIssueView {
    258         match self {
    259             Self::Issue(issue) | Self::MissingSellerAccount(issue) => issue,
    260         }
    261     }
    262 }
    263 
    264 impl From<ListingValidationIssueView> for ListingDraftValidationError {
    265     fn from(issue: ListingValidationIssueView) -> Self {
    266         Self::Issue(issue)
    267     }
    268 }
    269 
    270 #[derive(Debug, Clone, Copy)]
    271 pub enum ListingMutationOperation {
    272     Publish,
    273     Update,
    274     Archive,
    275 }
    276 
    277 impl ListingMutationOperation {
    278     fn as_str(self) -> &'static str {
    279         match self {
    280             Self::Publish => "publish",
    281             Self::Update => "update",
    282             Self::Archive => "archive",
    283         }
    284     }
    285 }
    286 
    287 pub fn scaffold(
    288     config: &RuntimeConfig,
    289     args: &ListingCreateArgs,
    290 ) -> Result<ListingNewView, RuntimeError> {
    291     let (draft, defaults) = build_listing_draft(config, args)?;
    292     let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?;
    293     write_listing_draft(&output_path, &draft, false)?;
    294     append_listing_local_work(config, output_path.as_path(), &draft)?;
    295 
    296     let mut actions = vec![format!(
    297         "radroots listing validate {}",
    298         output_path.display()
    299     )];
    300     if let Some(action) = &defaults.farm_next_action {
    301         actions.push(action.clone());
    302     }
    303 
    304     Ok(ListingNewView {
    305         state: "draft created".to_owned(),
    306         source: LISTING_SOURCE.to_owned(),
    307         file: output_path.display().to_string(),
    308         listing_id: draft.listing.d_tag,
    309         key: non_empty(draft.product.key.clone()),
    310         seller_account_id: Some(defaults.seller_account_id),
    311         seller_pubkey: Some(defaults.seller_pubkey),
    312         seller_actor_source: Some(defaults.seller_actor_source),
    313         farm_d_tag: defaults.selected_farm_d_tag,
    314         delivery_method: non_empty(draft.delivery.method.clone()),
    315         location_primary: non_empty(draft.location.primary.clone()),
    316         reason: defaults.farm_reason,
    317         actions,
    318     })
    319 }
    320 
    321 pub fn scaffold_preflight(
    322     config: &RuntimeConfig,
    323     args: &ListingCreateArgs,
    324 ) -> Result<ListingNewView, RuntimeError> {
    325     let (draft, defaults) = build_listing_draft(config, args)?;
    326     let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?;
    327     validate_listing_output_target(&output_path)?;
    328 
    329     let mut actions = vec![format!(
    330         "radroots listing validate {}",
    331         output_path.display()
    332     )];
    333     if let Some(action) = &defaults.farm_next_action {
    334         actions.push(action.clone());
    335     }
    336 
    337     Ok(ListingNewView {
    338         state: "dry_run".to_owned(),
    339         source: LISTING_SOURCE.to_owned(),
    340         file: output_path.display().to_string(),
    341         listing_id: draft.listing.d_tag,
    342         key: non_empty(draft.product.key.clone()),
    343         seller_account_id: Some(defaults.seller_account_id),
    344         seller_pubkey: Some(defaults.seller_pubkey),
    345         seller_actor_source: Some(defaults.seller_actor_source),
    346         farm_d_tag: defaults.selected_farm_d_tag,
    347         delivery_method: non_empty(draft.delivery.method.clone()),
    348         location_primary: non_empty(draft.location.primary.clone()),
    349         reason: Some("dry run requested; listing draft was not written".to_owned()),
    350         actions,
    351     })
    352 }
    353 
    354 fn build_listing_draft(
    355     config: &RuntimeConfig,
    356     args: &ListingCreateArgs,
    357 ) -> Result<(ListingDraftDocument, ListingAuthoringDefaults), RuntimeError> {
    358     let defaults = authoring_defaults(config)?;
    359     let quantity_unit = args.quantity_unit.clone().unwrap_or_else(|| "g".to_owned());
    360     let draft = ListingDraftDocument {
    361         version: 1,
    362         kind: DRAFT_KIND.to_owned(),
    363         listing: ListingDraftMeta {
    364             d_tag: generate_d_tag(),
    365             farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(),
    366         },
    367         seller_actor: ListingDraftSellerActor {
    368             account_id: defaults.seller_account_id.clone(),
    369             pubkey: defaults.seller_pubkey.clone(),
    370             source: defaults.seller_actor_source.clone(),
    371         },
    372         product: ListingDraftProduct {
    373             key: args.key.clone().unwrap_or_default(),
    374             title: args.title.clone().unwrap_or_default(),
    375             category: args.category.clone().unwrap_or_default(),
    376             summary: args.summary.clone().unwrap_or_default(),
    377         },
    378         primary_bin: ListingDraftPrimaryBin {
    379             bin_id: args.bin_id.clone().unwrap_or_else(|| "bin-1".to_owned()),
    380             quantity_amount: args
    381                 .quantity_amount
    382                 .clone()
    383                 .unwrap_or_else(|| "1000".to_owned()),
    384             quantity_unit: quantity_unit.clone(),
    385             price_amount: args
    386                 .price_amount
    387                 .clone()
    388                 .unwrap_or_else(|| "0.01".to_owned()),
    389             price_currency: args
    390                 .price_currency
    391                 .clone()
    392                 .unwrap_or_else(|| "USD".to_owned()),
    393             price_per_amount: args
    394                 .price_per_amount
    395                 .clone()
    396                 .unwrap_or_else(|| "1".to_owned()),
    397             price_per_unit: args
    398                 .price_per_unit
    399                 .clone()
    400                 .unwrap_or_else(|| quantity_unit.clone()),
    401             label: args.label.clone().unwrap_or_default(),
    402         },
    403         inventory: ListingDraftInventory {
    404             available: args.available.clone().unwrap_or_else(|| "1".to_owned()),
    405         },
    406         availability: ListingDraftAvailability {
    407             kind: "status".to_owned(),
    408             status: "active".to_owned(),
    409             start: None,
    410             end: None,
    411         },
    412         delivery: ListingDraftDelivery {
    413             method: defaults.delivery_method.clone().unwrap_or_default(),
    414         },
    415         location: defaults.location.clone().unwrap_or(ListingDraftLocation {
    416             primary: String::new(),
    417             city: None,
    418             region: None,
    419             country: None,
    420         }),
    421         discounts: listing_discount_drafts_from_args(args),
    422     };
    423     Ok((draft, defaults))
    424 }
    425 
    426 fn listing_discount_drafts_from_args(args: &ListingCreateArgs) -> Vec<ListingDraftDiscount> {
    427     let has_discount = args.discount_id.is_some()
    428         || args.discount_label.is_some()
    429         || args.discount_kind.is_some()
    430         || args.discount_value.is_some()
    431         || args.discount_amount.is_some()
    432         || args.discount_currency.is_some();
    433     if !has_discount {
    434         return Vec::new();
    435     }
    436     let kind = args.discount_kind.clone().unwrap_or_else(|| {
    437         if args.discount_amount.is_some() {
    438             "amount".to_owned()
    439         } else {
    440             "percent".to_owned()
    441         }
    442     });
    443     vec![ListingDraftDiscount {
    444         id: args
    445             .discount_id
    446             .clone()
    447             .unwrap_or_else(|| "discount_1".to_owned()),
    448         label: args.discount_label.clone().unwrap_or_default(),
    449         kind,
    450         value: args.discount_value.clone().unwrap_or_default(),
    451         amount: args.discount_amount.clone().unwrap_or_default(),
    452         currency: args.discount_currency.clone().unwrap_or_default(),
    453         bin_id: None,
    454         min_bin_count: None,
    455     }]
    456 }
    457 
    458 fn listing_output_path(
    459     config: &RuntimeConfig,
    460     explicit: Option<&std::path::PathBuf>,
    461     listing_id: &str,
    462 ) -> Result<std::path::PathBuf, RuntimeError> {
    463     match explicit {
    464         Some(path) => Ok(path.clone()),
    465         None => Ok(drafts_dir(config).join(format!("{listing_id}.toml"))),
    466     }
    467 }
    468 
    469 fn write_listing_draft(
    470     output_path: &Path,
    471     draft: &ListingDraftDocument,
    472     overwrite: bool,
    473 ) -> Result<(), RuntimeError> {
    474     if !overwrite {
    475         validate_listing_output_target(output_path)?;
    476     }
    477     if let Some(parent) = output_path.parent() {
    478         fs::create_dir_all(parent)?;
    479     }
    480     fs::write(output_path, scaffold_contents(draft)?)?;
    481     Ok(())
    482 }
    483 
    484 fn append_listing_local_work(
    485     config: &RuntimeConfig,
    486     path: &Path,
    487     draft: &ListingDraftDocument,
    488 ) -> Result<(), RuntimeError> {
    489     let listing_id = draft.listing.d_tag.trim();
    490     let seller_pubkey = draft.seller_actor.pubkey.trim();
    491     let listing_addr = if seller_pubkey.is_empty() || listing_id.is_empty() {
    492         None
    493     } else {
    494         Some(listing_addr(seller_pubkey, listing_id))
    495     };
    496     let payload = json!({
    497         "record_kind": DRAFT_KIND,
    498         "path": path.display().to_string(),
    499         "document": draft,
    500     });
    501     let subject = format!("listing:{}", draft.listing.d_tag);
    502     append_local_work(
    503         config,
    504         subject.as_str(),
    505         non_empty(draft.seller_actor.account_id.clone()),
    506         non_empty(draft.seller_actor.pubkey.clone()),
    507         non_empty(draft.listing.farm_d_tag.clone()),
    508         listing_addr,
    509         payload,
    510     )?;
    511     Ok(())
    512 }
    513 
    514 fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> {
    515     if output_path.exists() {
    516         return Err(RuntimeError::Config(format!(
    517             "listing draft output {} must not already exist",
    518             output_path.display()
    519         )));
    520     }
    521     if let Some(parent) = output_path.parent() {
    522         if parent.exists() && !parent.is_dir() {
    523             return Err(RuntimeError::Config(format!(
    524                 "listing draft parent {} is not a directory",
    525                 parent.display()
    526             )));
    527         }
    528     }
    529     Ok(())
    530 }
    531 
    532 pub fn validate(
    533     config: &RuntimeConfig,
    534     args: &ListingFileArgs,
    535 ) -> Result<ListingValidateView, RuntimeError> {
    536     let contents = fs::read_to_string(&args.file)?;
    537     let context = validation_context(config)?;
    538 
    539     let parsed = match toml::from_str::<ListingDraftDocument>(&contents) {
    540         Ok(parsed) => parsed,
    541         Err(error) => {
    542             return Ok(ListingValidateView {
    543                 state: "invalid".to_owned(),
    544                 source: LISTING_SOURCE.to_owned(),
    545                 file: args.file.display().to_string(),
    546                 valid: false,
    547                 listing_id: None,
    548                 seller_account_id: None,
    549                 seller_pubkey: None,
    550                 seller_actor_source: None,
    551                 farm_d_tag: None,
    552                 issues: vec![ListingValidationIssueView {
    553                     field: "toml".to_owned(),
    554                     message: error.to_string(),
    555                     line: error
    556                         .span()
    557                         .map(|span| line_for_offset(&contents, span.start + 1)),
    558                 }],
    559                 actions: vec![format!("edit {}", args.file.display())],
    560             });
    561         }
    562     };
    563 
    564     match canonicalize_draft(&parsed, &contents, &context) {
    565         Ok(canonical) => {
    566             let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) {
    567                 Ok(parts) => parts,
    568                 Err(error) => {
    569                     return Ok(invalid_validation_view(
    570                         args.file.as_path(),
    571                         &parsed,
    572                         &context,
    573                         ListingValidationIssueView {
    574                             field: "listing".to_owned(),
    575                             message: format!("invalid listing contract: {error}"),
    576                             line: None,
    577                         },
    578                     ));
    579                 }
    580             };
    581             if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? {
    582                 return Ok(invalid_validation_view(
    583                     args.file.as_path(),
    584                     &parsed,
    585                     &context,
    586                     issue,
    587                 ));
    588             }
    589             let event = RadrootsNostrEvent {
    590                 id: String::new(),
    591                 author: canonical.seller_pubkey.clone(),
    592                 created_at: 0,
    593                 kind: KIND_LISTING_DRAFT,
    594                 tags: parts.tags,
    595                 content: parts.content,
    596                 sig: String::new(),
    597             };
    598             match validate_listing_event(&event) {
    599                 Ok(_) => Ok(ListingValidateView {
    600                     state: "valid".to_owned(),
    601                     source: LISTING_SOURCE.to_owned(),
    602                     file: args.file.display().to_string(),
    603                     valid: true,
    604                     listing_id: Some(canonical.listing_id),
    605                     seller_account_id: Some(canonical.seller_account_id),
    606                     seller_pubkey: Some(canonical.seller_pubkey),
    607                     seller_actor_source: Some(canonical.seller_actor_source),
    608                     farm_d_tag: Some(canonical.farm_d_tag),
    609                     issues: Vec::new(),
    610                     actions: vec![format!("radroots listing publish {}", args.file.display())],
    611                 }),
    612                 Err(error) => Ok(invalid_validation_view(
    613                     args.file.as_path(),
    614                     &parsed,
    615                     &context,
    616                     issue_from_trade_validation(error, &contents),
    617                 )),
    618             }
    619         }
    620         Err(error) => Ok(invalid_validation_view(
    621             args.file.as_path(),
    622             &parsed,
    623             &context,
    624             error.into_issue(),
    625         )),
    626     }
    627 }
    628 
    629 pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> {
    630     let dir = drafts_dir(config);
    631     if !dir.exists() {
    632         return Ok(ListingListView {
    633             state: "empty".to_owned(),
    634             source: LISTING_SOURCE.to_owned(),
    635             count: 0,
    636             draft_dir: dir.display().to_string(),
    637             listings: Vec::new(),
    638             actions: vec!["radroots listing create".to_owned()],
    639         });
    640     }
    641 
    642     let context = validation_context(config).map_err(|error| error.to_string());
    643     let mut listings = Vec::new();
    644     for entry in fs::read_dir(&dir)? {
    645         let entry = entry?;
    646         let path = entry.path();
    647         if path.extension().and_then(|value| value.to_str()) != Some("toml") {
    648             continue;
    649         }
    650         match load_listing_draft(path.as_path()) {
    651             Ok(loaded) => listings.push(summary_from_loaded(config, &loaded, context.as_ref())),
    652             Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)),
    653         }
    654     }
    655 
    656     listings.sort_by(|left, right| {
    657         right
    658             .updated_at_unix
    659             .cmp(&left.updated_at_unix)
    660             .then_with(|| left.id.cmp(&right.id))
    661     });
    662 
    663     let state = if listings.is_empty() {
    664         "empty"
    665     } else if listings.iter().any(|listing| listing.state == "error") {
    666         "degraded"
    667     } else {
    668         "ready"
    669     };
    670     let actions = if listings.is_empty() {
    671         vec!["radroots listing create".to_owned()]
    672     } else {
    673         Vec::new()
    674     };
    675 
    676     Ok(ListingListView {
    677         state: state.to_owned(),
    678         source: LISTING_SOURCE.to_owned(),
    679         count: listings.len(),
    680         draft_dir: dir.display().to_string(),
    681         listings,
    682         actions,
    683     })
    684 }
    685 
    686 pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListView, RuntimeError> {
    687     let database_path = shared_local_events_db_path(config)?;
    688     let mut entries = current_app_record_entries(app_local_records(config)?);
    689     let has_more = entries.len() > APP_RECORD_LIST_LIMIT as usize;
    690     if has_more {
    691         entries.truncate(APP_RECORD_LIST_LIMIT as usize);
    692     }
    693     let next_cursor = if has_more {
    694         entries
    695             .last()
    696             .map(|entry| (entry.record.change_seq, entry.record.seq))
    697     } else {
    698         None
    699     };
    700     let records = entries
    701         .iter()
    702         .map(|entry| app_record_summary(&entry.record, entry.superseded_count))
    703         .collect::<Vec<_>>();
    704     let state = if records.is_empty() { "empty" } else { "ready" };
    705     let actions = if records.is_empty() {
    706         vec!["create or save a farm listing in radroots_app".to_owned()]
    707     } else {
    708         Vec::new()
    709     };
    710 
    711     Ok(ListingAppRecordListView {
    712         state: state.to_owned(),
    713         source: LISTING_APP_RECORD_SOURCE.to_owned(),
    714         count: records.len(),
    715         limit: APP_RECORD_LIST_LIMIT,
    716         has_more,
    717         next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq),
    718         next_before_seq: next_cursor.map(|(_, seq)| seq),
    719         local_events_db: database_path.display().to_string(),
    720         records,
    721         actions,
    722     })
    723 }
    724 
    725 pub fn app_record_export(
    726     config: &RuntimeConfig,
    727     args: &ListingAppRecordExportArgs,
    728 ) -> Result<ListingAppRecordExportView, RuntimeError> {
    729     let Some(record) = get_shared_record(config, args.record_id.as_str())? else {
    730         return Ok(ListingAppRecordExportView {
    731             state: "missing".to_owned(),
    732             source: LISTING_APP_RECORD_SOURCE.to_owned(),
    733             record_id: args.record_id.clone(),
    734             dry_run: config.output.dry_run,
    735             file: args
    736                 .output
    737                 .as_ref()
    738                 .map(|path| path.display().to_string())
    739                 .unwrap_or_default(),
    740             valid: false,
    741             listing_id: None,
    742             listing_addr: None,
    743             seller_account_id: None,
    744             seller_pubkey: None,
    745             seller_actor_source: None,
    746             farm_d_tag: None,
    747             issues: Vec::new(),
    748             reason: Some(format!(
    749                 "app-authored local record `{}` was not found",
    750                 args.record_id
    751             )),
    752             actions: vec!["radroots listing app list".to_owned()],
    753         });
    754     };
    755 
    756     if let Some(current_record) = current_app_record_for(config, &record)?
    757         && current_record.record_id != record.record_id
    758     {
    759         let (listing_id, title, farm_d_tag) = app_listing_display_parts(&record);
    760         let current_action = format!("radroots listing app export {}", current_record.record_id);
    761         return Ok(ListingAppRecordExportView {
    762             state: "stale".to_owned(),
    763             source: LISTING_APP_RECORD_SOURCE.to_owned(),
    764             record_id: args.record_id.clone(),
    765             dry_run: config.output.dry_run,
    766             file: args
    767                 .output
    768                 .as_ref()
    769                 .map(|path| path.display().to_string())
    770                 .unwrap_or_default(),
    771             valid: false,
    772             listing_id,
    773             listing_addr: record.listing_addr.clone(),
    774             seller_account_id: record.owner_account_id.clone(),
    775             seller_pubkey: record.owner_pubkey.clone(),
    776             seller_actor_source: None,
    777             farm_d_tag: farm_d_tag.or(record.farm_id.clone()),
    778             issues: vec![ListingValidationIssueView {
    779                 field: "record_id".to_owned(),
    780                 message: format!(
    781                     "app-authored local record `{}` was superseded by `{}`",
    782                     record.record_id, current_record.record_id
    783                 ),
    784                 line: None,
    785             }],
    786             reason: Some(format!(
    787                 "app-authored local record `{}` was superseded by current record `{}`{}",
    788                 record.record_id,
    789                 current_record.record_id,
    790                 title
    791                     .as_deref()
    792                     .map(|value| format!(" for `{value}`"))
    793                     .unwrap_or_default()
    794             )),
    795             actions: vec![current_action, "radroots listing app list".to_owned()],
    796         });
    797     }
    798 
    799     let draft = match app_listing_draft_from_record(&record) {
    800         Ok(draft) => draft,
    801         Err(reason) => {
    802             return Ok(ListingAppRecordExportView {
    803                 state: "unsupported".to_owned(),
    804                 source: LISTING_APP_RECORD_SOURCE.to_owned(),
    805                 record_id: args.record_id.clone(),
    806                 dry_run: config.output.dry_run,
    807                 file: args
    808                     .output
    809                     .as_ref()
    810                     .map(|path| path.display().to_string())
    811                     .unwrap_or_default(),
    812                 valid: false,
    813                 listing_id: None,
    814                 listing_addr: record.listing_addr.clone(),
    815                 seller_account_id: record.owner_account_id.clone(),
    816                 seller_pubkey: record.owner_pubkey.clone(),
    817                 seller_actor_source: None,
    818                 farm_d_tag: record.farm_id.clone(),
    819                 issues: vec![ListingValidationIssueView {
    820                     field: "local_work_json".to_owned(),
    821                     message: reason.clone(),
    822                     line: None,
    823                 }],
    824                 reason: Some(reason),
    825                 actions: vec!["radroots listing app list".to_owned()],
    826             });
    827         }
    828     };
    829     let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?;
    830     validate_listing_output_target(output_path.as_path())?;
    831     let contents = scaffold_contents(&draft)?;
    832     let context = validation_context(config)?;
    833     let issues = app_listing_export_issues(config, &draft, contents.as_str(), &context)?;
    834     let listing_addr_value = app_record_listing_addr(&draft);
    835 
    836     if !issues.is_empty() {
    837         return Ok(ListingAppRecordExportView {
    838             state: "invalid".to_owned(),
    839             source: LISTING_APP_RECORD_SOURCE.to_owned(),
    840             record_id: args.record_id.clone(),
    841             dry_run: config.output.dry_run,
    842             file: output_path.display().to_string(),
    843             valid: false,
    844             listing_id: non_empty(draft.listing.d_tag.clone()),
    845             listing_addr: listing_addr_value,
    846             seller_account_id: non_empty(draft.seller_actor.account_id.clone()),
    847             seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()),
    848             seller_actor_source: non_empty(draft.seller_actor.source.clone()),
    849             farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()),
    850             issues,
    851             reason: Some(format!(
    852                 "app-authored local record `{}` does not validate as a CLI listing draft",
    853                 args.record_id
    854             )),
    855             actions: vec!["radroots listing app list".to_owned()],
    856         });
    857     }
    858 
    859     if !config.output.dry_run {
    860         write_listing_draft(output_path.as_path(), &draft, false)?;
    861     }
    862 
    863     Ok(ListingAppRecordExportView {
    864         state: if config.output.dry_run {
    865             "dry_run"
    866         } else {
    867             "exported"
    868         }
    869         .to_owned(),
    870         source: LISTING_APP_RECORD_SOURCE.to_owned(),
    871         record_id: args.record_id.clone(),
    872         dry_run: config.output.dry_run,
    873         file: output_path.display().to_string(),
    874         valid: true,
    875         listing_id: Some(draft.listing.d_tag.clone()),
    876         listing_addr: app_record_listing_addr(&draft),
    877         seller_account_id: Some(draft.seller_actor.account_id.clone()),
    878         seller_pubkey: Some(draft.seller_actor.pubkey.clone()),
    879         seller_actor_source: Some(draft.seller_actor.source.clone()),
    880         farm_d_tag: Some(draft.listing.farm_d_tag.clone()),
    881         issues: Vec::new(),
    882         reason: Some(if config.output.dry_run {
    883             "dry run requested; listing draft was not written".to_owned()
    884         } else {
    885             "app-authored listing record exported as a CLI listing draft".to_owned()
    886         }),
    887         actions: vec![
    888             format!("radroots listing validate {}", output_path.display()),
    889             format!("radroots listing publish {}", output_path.display()),
    890         ],
    891     })
    892 }
    893 
    894 pub fn rebind(
    895     config: &RuntimeConfig,
    896     args: &ListingRebindArgs,
    897 ) -> Result<ListingRebindView, RuntimeError> {
    898     rebind_inner(config, args, false)
    899 }
    900 
    901 pub fn rebind_preflight(
    902     config: &RuntimeConfig,
    903     args: &ListingRebindArgs,
    904 ) -> Result<ListingRebindView, RuntimeError> {
    905     rebind_inner(config, args, true)
    906 }
    907 
    908 fn rebind_inner(
    909     config: &RuntimeConfig,
    910     args: &ListingRebindArgs,
    911     dry_run: bool,
    912 ) -> Result<ListingRebindView, RuntimeError> {
    913     let contents = fs::read_to_string(&args.file)?;
    914     let mut draft = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| {
    915         RuntimeError::Config(format!(
    916             "invalid listing draft {}: {error}",
    917             args.file.display()
    918         ))
    919     })?;
    920     let listing_id = draft.listing.d_tag.trim().to_owned();
    921     if !is_d_tag_base64url(&listing_id) {
    922         return Err(RuntimeError::Config(format!(
    923             "invalid listing draft {}: listing d_tag must be a 22-character base64url identifier",
    924             args.file.display()
    925         )));
    926     }
    927 
    928     let target_account = account::resolve_account_selector(config, args.selector.as_str())
    929         .map_err(|error| listing_rebind_selector_error(args.selector.as_str(), error))?;
    930     let from_seller_account_id = non_empty(draft.seller_actor.account_id.clone());
    931     let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone());
    932     let from_seller_actor_source = non_empty(draft.seller_actor.source.clone());
    933     let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone());
    934     let target_account_id = target_account.record.account_id.to_string();
    935     let target_pubkey = target_account.record.public_identity.public_key_hex.clone();
    936     let target_farm_d_tag = resolve_rebind_farm_d_tag(
    937         config,
    938         args,
    939         from_seller_account_id.as_deref(),
    940         from_farm_d_tag.as_deref(),
    941         target_account_id.as_str(),
    942     )?;
    943     let from_listing_addr = from_seller_pubkey
    944         .as_ref()
    945         .map(|pubkey| listing_addr(pubkey, listing_id.as_str()));
    946     let to_listing_addr = listing_addr(target_pubkey.as_str(), listing_id.as_str());
    947     let seller_pubkey_changed = from_seller_pubkey
    948         .as_deref()
    949         .map(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str()));
    950     let listing_addr_changed = from_listing_addr
    951         .as_deref()
    952         .map(|addr| addr != to_listing_addr.as_str());
    953     let farm_d_tag_changed = from_farm_d_tag
    954         .as_deref()
    955         .map(|d_tag| d_tag != target_farm_d_tag.as_str());
    956 
    957     draft.seller_actor.account_id = target_account_id.clone();
    958     draft.seller_actor.pubkey = target_pubkey.clone();
    959     draft.seller_actor.source = LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned();
    960     draft.listing.farm_d_tag = target_farm_d_tag.clone();
    961 
    962     if !dry_run {
    963         write_listing_draft(args.file.as_path(), &draft, true)?;
    964         append_listing_local_work(config, args.file.as_path(), &draft)?;
    965     }
    966 
    967     Ok(ListingRebindView {
    968         state: if dry_run { "dry_run" } else { "rebound" }.to_owned(),
    969         source: LISTING_SOURCE.to_owned(),
    970         file: args.file.display().to_string(),
    971         listing_id,
    972         dry_run,
    973         from_seller_account_id,
    974         from_seller_pubkey,
    975         from_seller_actor_source,
    976         to_seller_account_id: target_account_id,
    977         to_seller_pubkey: target_pubkey,
    978         to_seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(),
    979         seller_pubkey_changed,
    980         from_listing_addr,
    981         to_listing_addr,
    982         listing_addr_changed,
    983         from_farm_d_tag,
    984         to_farm_d_tag: target_farm_d_tag,
    985         farm_d_tag_changed,
    986         reason: Some(if dry_run {
    987             "dry run requested; listing seller actor binding was not written".to_owned()
    988         } else {
    989             "listing seller actor binding updated".to_owned()
    990         }),
    991         actions: if dry_run {
    992             vec![format!(
    993                 "radroots --approval-token approve listing rebind {} {}",
    994                 args.file.display(),
    995                 args.selector
    996             )]
    997         } else {
    998             vec![format!("radroots listing validate {}", args.file.display())]
    999         },
   1000     })
   1001 }
   1002 
   1003 fn resolve_rebind_farm_d_tag(
   1004     config: &RuntimeConfig,
   1005     args: &ListingRebindArgs,
   1006     from_seller_account_id: Option<&str>,
   1007     from_farm_d_tag: Option<&str>,
   1008     target_account_id: &str,
   1009 ) -> Result<String, RuntimeError> {
   1010     if let Some(explicit) = args
   1011         .farm_d_tag
   1012         .as_deref()
   1013         .map(str::trim)
   1014         .filter(|value| !value.is_empty())
   1015     {
   1016         if !is_d_tag_base64url(explicit) {
   1017             return Err(RuntimeError::Config(
   1018                 "listing rebind --farm-d-tag must be a 22-character base64url identifier"
   1019                     .to_owned(),
   1020             ));
   1021         }
   1022         return Ok(explicit.to_owned());
   1023     }
   1024     if from_seller_account_id == Some(target_account_id)
   1025         && let Some(existing) = from_farm_d_tag
   1026     {
   1027         return Ok(existing.to_owned());
   1028     }
   1029     if let Some(resolved) = farm_config::load(config, None)?
   1030         && resolved.document.selection.account == target_account_id
   1031     {
   1032         return Ok(resolved.document.selection.farm_d_tag);
   1033     }
   1034     Err(RuntimeError::Config(format!(
   1035         "listing rebind requires --farm-d-tag when target account `{target_account_id}` is not bound by the selected farm config"
   1036     )))
   1037 }
   1038 
   1039 fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError {
   1040     match error {
   1041         RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => {
   1042             account::AccountRuntimeFailure::unresolved_with_detail(
   1043                 issue.message().to_owned(),
   1044                 json!({
   1045                     "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_REBIND,
   1046                     "selector": selector,
   1047                     "actions": [
   1048                         "radroots account import <path>",
   1049                         "radroots account create",
   1050                     ],
   1051                 }),
   1052             )
   1053             .into()
   1054         }
   1055         other => other,
   1056     }
   1057 }
   1058 
   1059 fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String {
   1060     format!("{KIND_LISTING}:{seller_pubkey}:{listing_id}")
   1061 }
   1062 
   1063 fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> {
   1064     let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView {
   1065         field: "file".to_owned(),
   1066         message: format!("read listing draft {}: {error}", path.display()),
   1067         line: None,
   1068     })?;
   1069     let document = toml::from_str::<ListingDraftDocument>(contents.as_str()).map_err(|error| {
   1070         ListingValidationIssueView {
   1071             field: "toml".to_owned(),
   1072             message: error.to_string(),
   1073             line: error
   1074                 .span()
   1075                 .map(|span| line_for_offset(contents.as_str(), span.start + 1)),
   1076         }
   1077     })?;
   1078     Ok(LoadedListingDraft {
   1079         file: path.to_path_buf(),
   1080         updated_at_unix: modified_unix(path).unwrap_or_default(),
   1081         contents,
   1082         document,
   1083     })
   1084 }
   1085 
   1086 fn summary_from_loaded(
   1087     config: &RuntimeConfig,
   1088     loaded: &LoadedListingDraft,
   1089     context: Result<&ListingValidationContext, &String>,
   1090 ) -> ListingSummaryView {
   1091     let mut seller_account_id = non_empty(loaded.document.seller_actor.account_id.clone());
   1092     let mut seller_pubkey = non_empty(loaded.document.seller_actor.pubkey.clone());
   1093     let mut seller_actor_source = non_empty(loaded.document.seller_actor.source.clone());
   1094     let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone());
   1095     let mut issues = Vec::new();
   1096     let mut state = "draft";
   1097 
   1098     match context {
   1099         Ok(context) => {
   1100             match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) {
   1101                 Ok(canonical) => {
   1102                     seller_account_id = Some(canonical.seller_account_id.clone());
   1103                     seller_pubkey = Some(canonical.seller_pubkey.clone());
   1104                     seller_actor_source = Some(canonical.seller_actor_source.clone());
   1105                     farm_d_tag = Some(canonical.farm_d_tag.clone());
   1106                     issues = listing_ready_issues(&canonical, loaded.contents.as_str());
   1107                     if let Ok(Some(issue)) =
   1108                         listing_bound_account_issue(config, &canonical, loaded.contents.as_str())
   1109                     {
   1110                         issues.push(issue);
   1111                     }
   1112                     if issues.is_empty() {
   1113                         state = "ready";
   1114                     }
   1115                 }
   1116                 Err(error) => issues.push(error.into_issue()),
   1117             }
   1118         }
   1119         Err(reason) => issues.push(ListingValidationIssueView {
   1120             field: "context".to_owned(),
   1121             message: reason.to_string(),
   1122             line: None,
   1123         }),
   1124     }
   1125 
   1126     ListingSummaryView {
   1127         id: non_empty(loaded.document.listing.d_tag.clone())
   1128             .unwrap_or_else(|| file_stem(loaded.file.as_path())),
   1129         state: state.to_owned(),
   1130         file: loaded.file.display().to_string(),
   1131         product_key: non_empty(loaded.document.product.key.clone()),
   1132         title: non_empty(loaded.document.product.title.clone()),
   1133         category: non_empty(loaded.document.product.category.clone()),
   1134         seller_account_id,
   1135         seller_pubkey,
   1136         seller_actor_source,
   1137         farm_d_tag,
   1138         location_primary: non_empty(loaded.document.location.primary.clone()),
   1139         updated_at_unix: loaded.updated_at_unix,
   1140         issues,
   1141     }
   1142 }
   1143 
   1144 fn listing_ready_issues(
   1145     canonical: &CanonicalListingDraft,
   1146     contents: &str,
   1147 ) -> Vec<ListingValidationIssueView> {
   1148     let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) {
   1149         Ok(parts) => parts,
   1150         Err(error) => {
   1151             return vec![ListingValidationIssueView {
   1152                 field: "listing".to_owned(),
   1153                 message: format!("invalid listing contract: {error}"),
   1154                 line: None,
   1155             }];
   1156         }
   1157     };
   1158     let event = RadrootsNostrEvent {
   1159         id: String::new(),
   1160         author: canonical.seller_pubkey.clone(),
   1161         created_at: 0,
   1162         kind: KIND_LISTING_DRAFT,
   1163         tags: parts.tags,
   1164         content: parts.content,
   1165         sig: String::new(),
   1166     };
   1167     match validate_listing_event(&event) {
   1168         Ok(_) => Vec::new(),
   1169         Err(error) => vec![issue_from_trade_validation(error, contents)],
   1170     }
   1171 }
   1172 
   1173 fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> ListingSummaryView {
   1174     ListingSummaryView {
   1175         id: file_stem(path),
   1176         state: "error".to_owned(),
   1177         file: path.display().to_string(),
   1178         product_key: None,
   1179         title: None,
   1180         category: None,
   1181         seller_account_id: None,
   1182         seller_pubkey: None,
   1183         seller_actor_source: None,
   1184         farm_d_tag: None,
   1185         location_primary: None,
   1186         updated_at_unix: modified_unix(path).unwrap_or_default(),
   1187         issues: vec![issue],
   1188     }
   1189 }
   1190 
   1191 #[derive(Debug, Clone)]
   1192 struct AppRecordListEntry {
   1193     record: LocalEventRecord,
   1194     superseded_count: usize,
   1195 }
   1196 
   1197 fn app_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> {
   1198     let mut app_records = Vec::new();
   1199     let mut before_cursor = None::<(i64, i64)>;
   1200     loop {
   1201         let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor {
   1202             list_shared_records_before(
   1203                 config,
   1204                 before_change_seq,
   1205                 before_seq,
   1206                 APP_RECORD_LIST_LIMIT,
   1207             )?
   1208         } else {
   1209             list_shared_records_latest(config, APP_RECORD_LIST_LIMIT)?
   1210         };
   1211         let Some(next_cursor) = shared_records
   1212             .last()
   1213             .map(|record| (record.change_seq, record.seq))
   1214         else {
   1215             break;
   1216         };
   1217         let has_more = shared_records.len() == APP_RECORD_LIST_LIMIT as usize;
   1218         app_records.extend(
   1219             shared_records
   1220                 .into_iter()
   1221                 .filter(is_supported_app_local_record),
   1222         );
   1223         if !has_more {
   1224             break;
   1225         }
   1226         before_cursor = Some(next_cursor);
   1227     }
   1228     Ok(app_records)
   1229 }
   1230 
   1231 fn is_supported_app_local_record(record: &LocalEventRecord) -> bool {
   1232     record.source_runtime == SourceRuntime::App
   1233         && record.family == LocalRecordFamily::LocalWork
   1234         && matches!(
   1235             local_record_kind(record).as_deref(),
   1236             Some("farm_config_v1" | DRAFT_KIND)
   1237         )
   1238 }
   1239 
   1240 fn current_app_record_entries(mut records: Vec<LocalEventRecord>) -> Vec<AppRecordListEntry> {
   1241     records.sort_by(|left, right| {
   1242         right
   1243             .change_seq
   1244             .cmp(&left.change_seq)
   1245             .then_with(|| right.seq.cmp(&left.seq))
   1246             .then_with(|| left.record_id.cmp(&right.record_id))
   1247     });
   1248 
   1249     let mut entries: Vec<AppRecordListEntry> = Vec::new();
   1250     let mut seen = HashMap::<String, usize>::new();
   1251     for record in records {
   1252         let key = app_record_current_key(&record);
   1253         if let Some(index) = seen.get(&key).copied() {
   1254             entries[index].superseded_count += 1;
   1255         } else {
   1256             seen.insert(key, entries.len());
   1257             entries.push(AppRecordListEntry {
   1258                 record,
   1259                 superseded_count: 0,
   1260             });
   1261         }
   1262     }
   1263     entries
   1264 }
   1265 
   1266 fn current_app_record_for(
   1267     config: &RuntimeConfig,
   1268     record: &LocalEventRecord,
   1269 ) -> Result<Option<LocalEventRecord>, RuntimeError> {
   1270     let key = app_record_current_key(record);
   1271     Ok(app_local_records(config)?
   1272         .into_iter()
   1273         .filter(|candidate| app_record_current_key(candidate) == key)
   1274         .max_by(|left, right| {
   1275             left.change_seq
   1276                 .cmp(&right.change_seq)
   1277                 .then_with(|| left.seq.cmp(&right.seq))
   1278         }))
   1279 }
   1280 
   1281 fn app_record_summary(
   1282     record: &LocalEventRecord,
   1283     superseded_count: usize,
   1284 ) -> ListingAppRecordSummaryView {
   1285     let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned());
   1286     let (listing_id, title, exportable, reason) = match record_kind.as_str() {
   1287         DRAFT_KIND => {
   1288             if let Some(reason) = app_record_exportability_reason(record) {
   1289                 let (listing_id, title, _) = app_listing_display_parts(record);
   1290                 (listing_id, title, false, Some(reason))
   1291             } else {
   1292                 match app_listing_draft_from_record(record) {
   1293                     Ok(draft) => (
   1294                         non_empty(draft.listing.d_tag),
   1295                         non_empty(draft.product.title),
   1296                         true,
   1297                         None,
   1298                     ),
   1299                     Err(reason) => {
   1300                         let (listing_id, title, _) = app_listing_display_parts(record);
   1301                         (listing_id, title, false, Some(reason))
   1302                     }
   1303                 }
   1304             }
   1305         }
   1306         "farm_config_v1" => (
   1307             None,
   1308             record
   1309                 .local_work_json
   1310                 .as_ref()
   1311                 .and_then(|payload| payload["document"]["farm"]["name"].as_str())
   1312                 .map(str::to_owned),
   1313             false,
   1314             Some("farm records provide defaults; export selects listing records".to_owned()),
   1315         ),
   1316         _ => (
   1317             None,
   1318             None,
   1319             false,
   1320             Some(format!("unsupported app record kind `{record_kind}`")),
   1321         ),
   1322     };
   1323     let actions = if exportable {
   1324         vec![format!("radroots listing app export {}", record.record_id)]
   1325     } else {
   1326         Vec::new()
   1327     };
   1328 
   1329     ListingAppRecordSummaryView {
   1330         record_id: record.record_id.clone(),
   1331         seq: record.seq,
   1332         change_seq: record.change_seq,
   1333         superseded_count,
   1334         record_kind,
   1335         status: record.status.as_str().to_owned(),
   1336         source_runtime: record.source_runtime.as_str().to_owned(),
   1337         owner_account_id: record.owner_account_id.clone(),
   1338         owner_pubkey: record.owner_pubkey.clone(),
   1339         farm_id: record.farm_id.clone(),
   1340         listing_addr: record.listing_addr.clone(),
   1341         listing_id,
   1342         title,
   1343         exportable,
   1344         reason,
   1345         actions,
   1346     }
   1347 }
   1348 
   1349 fn app_record_current_key(record: &LocalEventRecord) -> String {
   1350     match local_record_kind(record).as_deref() {
   1351         Some(DRAFT_KIND) => {
   1352             if let Some(listing_addr) = record
   1353                 .listing_addr
   1354                 .as_deref()
   1355                 .map(str::trim)
   1356                 .filter(|value| !value.is_empty())
   1357             {
   1358                 return format!("listing_addr:{listing_addr}");
   1359             }
   1360             let (listing_id, _, _) = app_listing_display_parts(record);
   1361             if let (Some(owner_pubkey), Some(listing_id)) = (
   1362                 app_record_canonical_owner_pubkey(record),
   1363                 listing_id.filter(|value| is_d_tag_base64url(value)),
   1364             ) {
   1365                 return format!("listing_owner:{owner_pubkey}:{listing_id}");
   1366             }
   1367         }
   1368         Some("farm_config_v1") => {
   1369             if let Some(farm_id) = record
   1370                 .farm_id
   1371                 .as_deref()
   1372                 .map(str::trim)
   1373                 .filter(|value| !value.is_empty())
   1374             {
   1375                 return format!("farm:{farm_id}");
   1376             }
   1377             if let Some(farm_id) = record
   1378                 .local_work_json
   1379                 .as_ref()
   1380                 .and_then(|payload| payload["document"]["farm"]["d_tag"].as_str())
   1381                 .map(str::trim)
   1382                 .filter(|value| !value.is_empty())
   1383             {
   1384                 return format!("farm:{farm_id}");
   1385             }
   1386         }
   1387         _ => {}
   1388     }
   1389     format!("record:{}", record.record_id)
   1390 }
   1391 
   1392 fn canonical_hex_pubkey(value: &str) -> Option<String> {
   1393     let trimmed = value.trim();
   1394     if trimmed.len() == 64 && trimmed.chars().all(|char| char.is_ascii_hexdigit()) {
   1395         Some(trimmed.to_ascii_lowercase())
   1396     } else {
   1397         None
   1398     }
   1399 }
   1400 
   1401 fn app_record_canonical_owner_pubkey(record: &LocalEventRecord) -> Option<String> {
   1402     record
   1403         .owner_pubkey
   1404         .as_deref()
   1405         .and_then(canonical_hex_pubkey)
   1406 }
   1407 
   1408 fn app_listing_display_parts(
   1409     record: &LocalEventRecord,
   1410 ) -> (Option<String>, Option<String>, Option<String>) {
   1411     let document = record
   1412         .local_work_json
   1413         .as_ref()
   1414         .and_then(|payload| payload.get("document"));
   1415     let listing_id = document
   1416         .and_then(|document| document["listing"]["d_tag"].as_str())
   1417         .map(str::trim)
   1418         .filter(|value| !value.is_empty())
   1419         .map(str::to_owned);
   1420     let title = document
   1421         .and_then(|document| document["product"]["title"].as_str())
   1422         .map(str::trim)
   1423         .filter(|value| !value.is_empty())
   1424         .map(str::to_owned);
   1425     let farm_d_tag = document
   1426         .and_then(|document| document["listing"]["farm_d_tag"].as_str())
   1427         .map(str::trim)
   1428         .filter(|value| !value.is_empty())
   1429         .map(str::to_owned);
   1430     (listing_id, title, farm_d_tag)
   1431 }
   1432 
   1433 fn app_record_exportability_reason(record: &LocalEventRecord) -> Option<String> {
   1434     if local_record_kind(record).as_deref() == Some(DRAFT_KIND)
   1435         && app_record_canonical_owner_pubkey(record).is_none()
   1436     {
   1437         return Some(CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned());
   1438     }
   1439     let exportability = record
   1440         .local_work_json
   1441         .as_ref()
   1442         .and_then(|payload| payload.get("exportability"))?;
   1443     let state = exportability
   1444         .get("state")
   1445         .and_then(Value::as_str)
   1446         .unwrap_or_default();
   1447     if state.is_empty() || state == "exportable" {
   1448         return None;
   1449     }
   1450     let reason = exportability
   1451         .get("reason")
   1452         .and_then(Value::as_str)
   1453         .unwrap_or_default();
   1454     Some(match (state, reason) {
   1455         ("identity_unresolved", "canonical_hex_pubkey_required") => {
   1456             CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned()
   1457         }
   1458         ("identity_unresolved", _) => "app record identity is unresolved".to_owned(),
   1459         (_, "") => format!("app record exportability state `{state}` is not exportable"),
   1460         (_, reason) => format!("app record exportability state `{state}`: {reason}"),
   1461     })
   1462 }
   1463 
   1464 fn app_listing_draft_from_record(
   1465     record: &LocalEventRecord,
   1466 ) -> Result<ListingDraftDocument, String> {
   1467     if record.source_runtime != SourceRuntime::App {
   1468         return Err(format!(
   1469             "record source_runtime `{}` is not app",
   1470             record.source_runtime.as_str()
   1471         ));
   1472     }
   1473     if record.family != LocalRecordFamily::LocalWork {
   1474         return Err(format!(
   1475             "record family `{}` is not local_work",
   1476             record.family.as_str()
   1477         ));
   1478     }
   1479     let payload = record
   1480         .local_work_json
   1481         .as_ref()
   1482         .ok_or_else(|| "record has no local_work_json payload".to_owned())?;
   1483     let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned());
   1484     if record_kind != DRAFT_KIND {
   1485         return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}"));
   1486     }
   1487     if let Some(reason) = app_record_exportability_reason(record) {
   1488         return Err(reason);
   1489     }
   1490     let owner_pubkey = app_record_canonical_owner_pubkey(record)
   1491         .ok_or_else(|| CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned())?;
   1492     let document = payload
   1493         .get("document")
   1494         .cloned()
   1495         .ok_or_else(|| "record local_work_json.document is missing".to_owned())?;
   1496     let mut draft = serde_json::from_value::<ListingDraftDocument>(document)
   1497         .map_err(|error| format!("record listing document is invalid: {error}"))?;
   1498     if let Some(account_id) = record
   1499         .owner_account_id
   1500         .as_deref()
   1501         .map(str::trim)
   1502         .filter(|value| !value.is_empty())
   1503     {
   1504         draft.seller_actor.account_id = account_id.to_owned();
   1505     }
   1506     draft.seller_actor.pubkey = owner_pubkey;
   1507     if draft.listing.farm_d_tag.trim().is_empty()
   1508         && let Some(farm_id) = record.farm_id.as_ref()
   1509     {
   1510         draft.listing.farm_d_tag = farm_id.clone();
   1511     }
   1512     normalize_app_listing_availability(&mut draft)?;
   1513     normalize_app_listing_units(&mut draft);
   1514     Ok(draft)
   1515 }
   1516 
   1517 fn normalize_app_listing_availability(draft: &mut ListingDraftDocument) -> Result<(), String> {
   1518     let kind = draft.availability.kind.trim();
   1519     if kind.is_empty() || kind == "local" {
   1520         draft.availability.kind = "status".to_owned();
   1521     } else if !matches!(kind, "status" | "window") {
   1522         return Err(format!(
   1523             "unsupported app listing availability kind `{kind}`"
   1524         ));
   1525     }
   1526     if draft.availability.kind == "window" {
   1527         return Ok(());
   1528     }
   1529 
   1530     let status = draft.availability.status.trim();
   1531     draft.availability.status = match status {
   1532         "" | "active" | "draft" | "published" => "active".to_owned(),
   1533         "archived" | "paused" | "sold" => {
   1534             return Err(format!(
   1535                 "app listing status `{status}` is not exportable as a publishable CLI draft"
   1536             ));
   1537         }
   1538         other => return Err(format!("unsupported app listing status `{other}`")),
   1539     };
   1540     Ok(())
   1541 }
   1542 
   1543 fn normalize_app_listing_units(draft: &mut ListingDraftDocument) {
   1544     let quantity_unit = draft.primary_bin.quantity_unit.trim().to_owned();
   1545     let price_per_unit = draft.primary_bin.price_per_unit.trim().to_owned();
   1546     let quantity_unit_supported = quantity_unit.parse::<RadrootsCoreUnit>().is_ok();
   1547     let price_per_unit_supported = price_per_unit.parse::<RadrootsCoreUnit>().is_ok();
   1548     if quantity_unit_supported && price_per_unit_supported {
   1549         return;
   1550     }
   1551 
   1552     if draft.primary_bin.label.trim().is_empty() {
   1553         draft.primary_bin.label = if !quantity_unit_supported && !quantity_unit.is_empty() {
   1554             quantity_unit.clone()
   1555         } else {
   1556             price_per_unit.clone()
   1557         };
   1558     }
   1559     if !quantity_unit_supported {
   1560         draft.primary_bin.quantity_unit = "each".to_owned();
   1561     }
   1562     if !price_per_unit_supported {
   1563         draft.primary_bin.price_per_unit = "each".to_owned();
   1564     }
   1565 }
   1566 
   1567 fn app_listing_export_issues(
   1568     config: &RuntimeConfig,
   1569     draft: &ListingDraftDocument,
   1570     contents: &str,
   1571     context: &ListingValidationContext,
   1572 ) -> Result<Vec<ListingValidationIssueView>, RuntimeError> {
   1573     let canonical = match canonicalize_draft(draft, contents, context) {
   1574         Ok(canonical) => canonical,
   1575         Err(error) => return Ok(vec![error.into_issue()]),
   1576     };
   1577     let mut issues = listing_ready_issues(&canonical, contents);
   1578     if let Some(issue) = listing_bound_account_issue(config, &canonical, contents)? {
   1579         issues.push(issue);
   1580     }
   1581     Ok(issues)
   1582 }
   1583 
   1584 fn app_record_listing_addr(draft: &ListingDraftDocument) -> Option<String> {
   1585     let seller_pubkey = draft.seller_actor.pubkey.trim();
   1586     let listing_id = draft.listing.d_tag.trim();
   1587     if seller_pubkey.is_empty() || listing_id.is_empty() {
   1588         None
   1589     } else {
   1590         Some(listing_addr(seller_pubkey, listing_id))
   1591     }
   1592 }
   1593 
   1594 fn local_record_kind(record: &LocalEventRecord) -> Option<String> {
   1595     record
   1596         .local_work_json
   1597         .as_ref()
   1598         .and_then(|payload| payload.get("record_kind"))
   1599         .and_then(Value::as_str)
   1600         .map(str::to_owned)
   1601 }
   1602 
   1603 pub fn get(
   1604     config: &RuntimeConfig,
   1605     args: &RecordLookupArgs,
   1606 ) -> Result<ListingGetView, RuntimeError> {
   1607     refresh_market_listing_if_needed(config)?;
   1608     let freshness = if config.local.replica_db_path.exists() {
   1609         let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
   1610         freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketRefresh)?
   1611     } else {
   1612         missing_freshness()
   1613     };
   1614     let provenance = FindResultProvenanceView {
   1615         origin: "local_replica.trade_product".to_owned(),
   1616         freshness: freshness.display.clone(),
   1617         relay_count: config.relay.urls.len(),
   1618     };
   1619 
   1620     if !config.local.replica_db_path.exists() {
   1621         return Ok(ListingGetView {
   1622             state: "unconfigured".to_owned(),
   1623             source: LISTING_READ_SOURCE.to_owned(),
   1624             lookup: args.key.clone(),
   1625             readiness: MarketReadinessView::unavailable("local_replica_not_initialized"),
   1626             listing_id: None,
   1627             product_key: None,
   1628             listing_addr: None,
   1629             primary_bin_id: None,
   1630             title: None,
   1631             category: None,
   1632             description: None,
   1633             location_primary: None,
   1634             available: None,
   1635             price: None,
   1636             provenance,
   1637             reason: Some("local replica database is not initialized".to_owned()),
   1638             actions: vec!["radroots store init".to_owned()],
   1639         });
   1640     }
   1641 
   1642     let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?);
   1643     let rows = db.trade_product_lookup(args.key.as_str())?;
   1644     let Some(row) = rows.into_iter().next() else {
   1645         return Ok(ListingGetView {
   1646             state: "missing".to_owned(),
   1647             source: LISTING_READ_SOURCE.to_owned(),
   1648             lookup: args.key.clone(),
   1649             readiness: MarketReadinessView::unavailable("market_listing_missing"),
   1650             listing_id: None,
   1651             product_key: None,
   1652             listing_addr: None,
   1653             primary_bin_id: None,
   1654             title: None,
   1655             category: None,
   1656             description: None,
   1657             location_primary: None,
   1658             available: None,
   1659             price: None,
   1660             provenance,
   1661             reason: Some(format!(
   1662                 "listing `{}` is not available in the local replica",
   1663                 args.key
   1664             )),
   1665             actions: vec![
   1666                 "radroots sync pull".to_owned(),
   1667                 format!("radroots market product search {}", args.key),
   1668             ],
   1669         });
   1670     };
   1671 
   1672     let listing_addr = row.listing_addr.and_then(non_empty);
   1673     let primary_bin_id = row.primary_bin_id.and_then(non_empty);
   1674     let verified_primary_bin_id = row.verified_primary_bin_id.and_then(non_empty);
   1675     let available_amount = row.qty_avail;
   1676     let price_amount = row.price_amt;
   1677     let price_currency = row.price_currency;
   1678     let price_per_amount = row.price_qty_amt;
   1679     let readiness = MarketReadinessView::from_market_projection(
   1680         listing_addr.as_deref(),
   1681         primary_bin_id.as_deref(),
   1682         verified_primary_bin_id.as_deref(),
   1683         Some(row.title.as_str()),
   1684         Some(row.category.as_str()),
   1685         available_amount,
   1686         price_amount,
   1687         price_currency.as_str(),
   1688         price_per_amount,
   1689     );
   1690 
   1691     Ok(ListingGetView {
   1692         state: "ready".to_owned(),
   1693         source: LISTING_READ_SOURCE.to_owned(),
   1694         lookup: args.key.clone(),
   1695         readiness,
   1696         listing_id: Some(row.id),
   1697         product_key: Some(row.key),
   1698         listing_addr,
   1699         primary_bin_id,
   1700         title: Some(row.title),
   1701         category: Some(row.category),
   1702         description: non_empty(row.summary),
   1703         location_primary: row.location_primary.and_then(non_empty),
   1704         available: Some(FindQuantityView {
   1705             total_amount: row.qty_amt,
   1706             total_unit: row.qty_unit,
   1707             label: row.qty_label.and_then(non_empty),
   1708             available_amount,
   1709         }),
   1710         price: Some(FindPriceView {
   1711             amount: price_amount,
   1712             currency: price_currency,
   1713             per_amount: price_per_amount,
   1714             per_unit: row.price_qty_unit,
   1715         }),
   1716         provenance,
   1717         reason: None,
   1718         actions: Vec::new(),
   1719     })
   1720 }
   1721 
   1722 fn refresh_market_listing_if_needed(config: &RuntimeConfig) -> Result<(), RuntimeError> {
   1723     if !config.local.replica_db_path.exists()
   1724         || config.output.dry_run
   1725         || config.relay.urls.is_empty()
   1726     {
   1727         return Ok(());
   1728     }
   1729     let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
   1730     let freshness =
   1731         freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketRefresh)?;
   1732     if crate::runtime::sync::freshness_requires_refresh(&freshness) {
   1733         let _ = market_refresh(config)?;
   1734     }
   1735     Ok(())
   1736 }
   1737 
   1738 pub fn publish_via_sdk(
   1739     config: &RuntimeConfig,
   1740     args: &ListingMutationArgs,
   1741 ) -> Result<ListingMutationView, CliSdkAdapterError> {
   1742     let input = sdk_listing_publish_input(config, args)?;
   1743     if config.output.dry_run {
   1744         validate_configured_listing_signer(config, &input.canonical)?;
   1745         let session = CliSdkSession::connect_memory(config)?;
   1746         let plan = session.sdk().listings().prepare_publish(
   1747             ListingPreparePublishRequest::from_document(
   1748                 input.actor.clone(),
   1749                 input.document.clone(),
   1750             ),
   1751         )?;
   1752         return Ok(sdk_prepared_publish_view(
   1753             config,
   1754             args,
   1755             ListingMutationOperation::Publish,
   1756             &input.canonical,
   1757             plan,
   1758         ));
   1759     }
   1760 
   1761     let session = CliSdkSession::connect_for_actor(
   1762         config,
   1763         Some(input.canonical.seller_account_id.as_str()),
   1764         input.canonical.seller_pubkey.as_str(),
   1765         "listing seller",
   1766     )?;
   1767     let mut request = ListingEnqueuePublishRequest::from_document(
   1768         input.actor,
   1769         input.document,
   1770         sdk_relay_target_policy(config),
   1771     );
   1772     if let Some(idempotency_key) = args.idempotency_key.as_deref() {
   1773         request = request.try_with_idempotency_key(idempotency_key)?;
   1774     }
   1775     let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?;
   1776     let push_receipt = if args.offline {
   1777         None
   1778     } else {
   1779         Some(
   1780             session.block_on(
   1781                 session.sdk().sync().push_outbox(
   1782                     PushOutboxRequest::new()
   1783                         .with_limit(1)
   1784                         .with_relay_url_policy(sdk_relay_url_policy(config)),
   1785                 ),
   1786             )?,
   1787         )
   1788     };
   1789     Ok(sdk_enqueued_publish_view(
   1790         config,
   1791         args,
   1792         ListingMutationOperation::Publish,
   1793         &input.canonical,
   1794         enqueue_receipt,
   1795         push_receipt,
   1796     ))
   1797 }
   1798 
   1799 fn sdk_listing_publish_input(
   1800     config: &RuntimeConfig,
   1801     args: &ListingMutationArgs,
   1802 ) -> Result<SdkListingPublishInput, RuntimeError> {
   1803     let contents = fs::read_to_string(&args.file)?;
   1804     let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| {
   1805         RuntimeError::Config(format!(
   1806             "invalid listing draft {}: {error}",
   1807             args.file.display()
   1808         ))
   1809     })?;
   1810     let context = mutation_validation_context(config)?;
   1811     let canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| {
   1812         let issue = match error {
   1813             ListingDraftValidationError::MissingSellerAccount(issue) => {
   1814                 return account::AccountRuntimeFailure::unresolved_with_detail(
   1815                     format!("{} ({})", issue.message, issue.field),
   1816                     json!({
   1817                         "seller_actor_source": "listing_draft",
   1818                         "listing_file": args.file.display().to_string(),
   1819                         "actions": listing_bound_account_recovery_actions(args.file.as_path()),
   1820                     }),
   1821                 )
   1822                 .into();
   1823             }
   1824             ListingDraftValidationError::Issue(issue) => issue,
   1825         };
   1826         RuntimeError::Config(format!(
   1827             "invalid listing draft {}: {} ({})",
   1828             args.file.display(),
   1829             issue.message,
   1830             issue.field
   1831         ))
   1832     })?;
   1833     ensure_listing_bound_account(config, &canonical, args.file.as_path())?;
   1834     let actor = RadrootsActorContext::local_account(
   1835         canonical.seller_pubkey.as_str(),
   1836         canonical.seller_account_id.clone(),
   1837         [RadrootsActorRole::Seller],
   1838     )
   1839     .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?;
   1840     let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone());
   1841     Ok(SdkListingPublishInput {
   1842         canonical,
   1843         actor,
   1844         document,
   1845     })
   1846 }
   1847 
   1848 fn sdk_prepared_publish_view(
   1849     config: &RuntimeConfig,
   1850     args: &ListingMutationArgs,
   1851     operation: ListingMutationOperation,
   1852     canonical: &CanonicalListingDraft,
   1853     plan: ListingPublishPlan,
   1854 ) -> ListingMutationView {
   1855     let listing_addr = plan.public_listing_addr.as_str().to_owned();
   1856     let event = sdk_plan_event_view(&plan);
   1857     ListingMutationView {
   1858         state: "dry_run".to_owned(),
   1859         operation: operation.as_str().to_owned(),
   1860         source: SDK_LISTING_WRITE_SOURCE.to_owned(),
   1861         file: args.file.display().to_string(),
   1862         listing_id: canonical.listing_id.clone(),
   1863         listing_addr: listing_addr.clone(),
   1864         seller_account_id: canonical.seller_account_id.clone(),
   1865         seller_pubkey: canonical.seller_pubkey.clone(),
   1866         seller_actor_source: canonical.seller_actor_source.clone(),
   1867         event_kind: KIND_LISTING,
   1868         dry_run: true,
   1869         deduplicated: false,
   1870         target_relays: Vec::new(),
   1871         connected_relays: Vec::new(),
   1872         acknowledged_relays: Vec::new(),
   1873         failed_relays: Vec::new(),
   1874         job_id: None,
   1875         job_status: None,
   1876         signer_mode: Some(config.signer.backend.as_str().to_owned()),
   1877         event_id: Some(plan.expected_event_id.as_str().to_owned()),
   1878         event_addr: Some(listing_addr),
   1879         idempotency_key: args.idempotency_key.clone(),
   1880         local_replica: None,
   1881         reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()),
   1882         job: None,
   1883         event: args.print_event.then_some(event),
   1884         actions: vec![format!("radroots listing publish {}", args.file.display())],
   1885     }
   1886 }
   1887 
   1888 fn sdk_enqueued_publish_view(
   1889     config: &RuntimeConfig,
   1890     args: &ListingMutationArgs,
   1891     operation: ListingMutationOperation,
   1892     canonical: &CanonicalListingDraft,
   1893     enqueue: ListingEnqueueReceipt,
   1894     push: Option<PushOutboxReceipt>,
   1895 ) -> ListingMutationView {
   1896     let push_event = push
   1897         .as_ref()
   1898         .and_then(|receipt| sdk_push_event_for_listing(&enqueue, receipt));
   1899     let state = sdk_publish_state(args, push_event);
   1900     let reason = sdk_publish_reason(args, push_event);
   1901     let target_relays = push_event
   1902         .map(sdk_push_target_relays)
   1903         .unwrap_or_else(|| config.relay.urls.clone());
   1904     let connected_relays = push_event
   1905         .map(sdk_push_connected_relays)
   1906         .unwrap_or_default();
   1907     let acknowledged_relays = push_event
   1908         .map(sdk_push_acknowledged_relays)
   1909         .unwrap_or_default();
   1910     let failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default();
   1911     let event_id = enqueue.signed_event_id.as_str().to_owned();
   1912     let listing_addr = enqueue.public_listing_addr.as_str().to_owned();
   1913     ListingMutationView {
   1914         state,
   1915         operation: operation.as_str().to_owned(),
   1916         source: SDK_LISTING_WRITE_SOURCE.to_owned(),
   1917         file: args.file.display().to_string(),
   1918         listing_id: canonical.listing_id.clone(),
   1919         listing_addr: listing_addr.clone(),
   1920         seller_account_id: canonical.seller_account_id.clone(),
   1921         seller_pubkey: canonical.seller_pubkey.clone(),
   1922         seller_actor_source: canonical.seller_actor_source.clone(),
   1923         event_kind: KIND_LISTING,
   1924         dry_run: false,
   1925         deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued),
   1926         target_relays,
   1927         connected_relays,
   1928         acknowledged_relays,
   1929         failed_relays,
   1930         job_id: None,
   1931         job_status: None,
   1932         signer_mode: Some(config.signer.backend.as_str().to_owned()),
   1933         event_id: Some(event_id),
   1934         event_addr: Some(listing_addr),
   1935         idempotency_key: args.idempotency_key.clone(),
   1936         local_replica: None,
   1937         reason,
   1938         job: None,
   1939         event: None,
   1940         actions: sdk_publish_actions(args, push_event),
   1941     }
   1942 }
   1943 
   1944 fn sdk_plan_event_view(plan: &ListingPublishPlan) -> ListingMutationEventView {
   1945     ListingMutationEventView {
   1946         kind: plan.frozen_draft.kind,
   1947         author: plan.frozen_draft.expected_pubkey.clone(),
   1948         created_at: Some(plan.frozen_draft.created_at),
   1949         content: plan.frozen_draft.content.clone(),
   1950         tags: plan.frozen_draft.tags.clone(),
   1951         event_id: Some(plan.expected_event_id.as_str().to_owned()),
   1952         signature: None,
   1953         event_addr: plan.public_listing_addr.as_str().to_owned(),
   1954     }
   1955 }
   1956 
   1957 fn sdk_push_event_for_listing<'a>(
   1958     enqueue: &ListingEnqueueReceipt,
   1959     push: &'a PushOutboxReceipt,
   1960 ) -> Option<&'a PushOutboxEventReceipt> {
   1961     push.events
   1962         .iter()
   1963         .find(|event| event.event_id == enqueue.signed_event_id)
   1964 }
   1965 
   1966 fn sdk_publish_state(
   1967     args: &ListingMutationArgs,
   1968     push_event: Option<&PushOutboxEventReceipt>,
   1969 ) -> String {
   1970     match push_event.map(|event| event.final_state) {
   1971         Some(PushOutboxEventState::Published) => "published",
   1972         Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => {
   1973             "unavailable"
   1974         }
   1975         Some(_) | None if args.offline => "queued",
   1976         Some(_) | None => "queued",
   1977     }
   1978     .to_owned()
   1979 }
   1980 
   1981 fn sdk_publish_reason(
   1982     args: &ListingMutationArgs,
   1983     push_event: Option<&PushOutboxEventReceipt>,
   1984 ) -> Option<String> {
   1985     match push_event.map(|event| event.final_state) {
   1986         Some(PushOutboxEventState::Published) => None,
   1987         Some(PushOutboxEventState::PublishRetryable) => Some(
   1988             "SDK relay publish did not reach accepted quorum; outbox event remains retryable"
   1989                 .to_owned(),
   1990         ),
   1991         Some(PushOutboxEventState::FailedTerminal) => {
   1992             Some("SDK relay publish failed terminally".to_owned())
   1993         }
   1994         Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")),
   1995         None if args.offline => Some(
   1996             "listing publish queued in SDK outbox; relay push skipped for offline mode".to_owned(),
   1997         ),
   1998         None => Some(
   1999             "listing publish queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(),
   2000         ),
   2001     }
   2002 }
   2003 
   2004 fn sdk_publish_actions(
   2005     args: &ListingMutationArgs,
   2006     push_event: Option<&PushOutboxEventReceipt>,
   2007 ) -> Vec<String> {
   2008     if args.offline
   2009         || !matches!(
   2010             push_event.map(|event| event.final_state),
   2011             Some(PushOutboxEventState::Published)
   2012         )
   2013     {
   2014         return vec!["radroots sync push".to_owned()];
   2015     }
   2016     Vec::new()
   2017 }
   2018 
   2019 fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
   2020     event
   2021         .relays
   2022         .iter()
   2023         .map(|relay| relay.relay_url.clone())
   2024         .collect()
   2025 }
   2026 
   2027 fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
   2028     event
   2029         .relays
   2030         .iter()
   2031         .filter(|relay| relay.attempted)
   2032         .map(|relay| relay.relay_url.clone())
   2033         .collect()
   2034 }
   2035 
   2036 fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> {
   2037     event
   2038         .relays
   2039         .iter()
   2040         .filter(|relay| {
   2041             matches!(
   2042                 relay.outcome_kind,
   2043                 PushOutboxRelayOutcomeKind::Accepted
   2044                     | PushOutboxRelayOutcomeKind::DuplicateAccepted
   2045             )
   2046         })
   2047         .map(|relay| relay.relay_url.clone())
   2048         .collect()
   2049 }
   2050 
   2051 fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> {
   2052     event
   2053         .relays
   2054         .iter()
   2055         .filter(|relay| {
   2056             !matches!(
   2057                 relay.outcome_kind,
   2058                 PushOutboxRelayOutcomeKind::Accepted
   2059                     | PushOutboxRelayOutcomeKind::DuplicateAccepted
   2060             )
   2061         })
   2062         .map(|relay| RelayFailureView {
   2063             relay: relay.relay_url.clone(),
   2064             reason: relay
   2065                 .message
   2066                 .clone()
   2067                 .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()),
   2068         })
   2069         .collect()
   2070 }
   2071 
   2072 fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str {
   2073     match kind {
   2074         PushOutboxRelayOutcomeKind::Accepted => "accepted",
   2075         PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted",
   2076         PushOutboxRelayOutcomeKind::Blocked => "blocked",
   2077         PushOutboxRelayOutcomeKind::RateLimited => "rate_limited",
   2078         PushOutboxRelayOutcomeKind::Invalid => "invalid",
   2079         PushOutboxRelayOutcomeKind::PowRequired => "pow_required",
   2080         PushOutboxRelayOutcomeKind::Restricted => "restricted",
   2081         PushOutboxRelayOutcomeKind::AuthRequired => "auth_required",
   2082         PushOutboxRelayOutcomeKind::Error => "error",
   2083         PushOutboxRelayOutcomeKind::Timeout => "timeout",
   2084         PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed",
   2085         PushOutboxRelayOutcomeKind::Unknown => "unknown",
   2086         _ => "unknown",
   2087     }
   2088 }
   2089 
   2090 pub fn update(
   2091     config: &RuntimeConfig,
   2092     args: &ListingMutationArgs,
   2093 ) -> Result<ListingMutationView, CliSdkAdapterError> {
   2094     mutate(config, args, ListingMutationOperation::Update)
   2095 }
   2096 
   2097 pub fn archive(
   2098     config: &RuntimeConfig,
   2099     args: &ListingMutationArgs,
   2100 ) -> Result<ListingMutationView, CliSdkAdapterError> {
   2101     mutate(config, args, ListingMutationOperation::Archive)
   2102 }
   2103 
   2104 fn mutate(
   2105     config: &RuntimeConfig,
   2106     args: &ListingMutationArgs,
   2107     operation: ListingMutationOperation,
   2108 ) -> Result<ListingMutationView, CliSdkAdapterError> {
   2109     let contents = fs::read_to_string(&args.file).map_err(RuntimeError::from)?;
   2110     let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| {
   2111         RuntimeError::Config(format!(
   2112             "invalid listing draft {}: {error}",
   2113             args.file.display()
   2114         ))
   2115     })?;
   2116     let context = mutation_validation_context(config)?;
   2117     let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| {
   2118         let issue = match error {
   2119             ListingDraftValidationError::MissingSellerAccount(issue) => {
   2120                 return account::AccountRuntimeFailure::unresolved_with_detail(
   2121                     format!("{} ({})", issue.message, issue.field),
   2122                     json!({
   2123                         "seller_actor_source": "listing_draft",
   2124                         "listing_file": args.file.display().to_string(),
   2125                         "actions": listing_bound_account_recovery_actions(args.file.as_path()),
   2126                     }),
   2127                 )
   2128                 .into();
   2129             }
   2130             ListingDraftValidationError::Issue(issue) => issue,
   2131         };
   2132         RuntimeError::Config(format!(
   2133             "invalid listing draft {}: {} ({})",
   2134             args.file.display(),
   2135             issue.message,
   2136             issue.field
   2137         ))
   2138     })?;
   2139     ensure_listing_bound_account(config, &canonical, args.file.as_path())?;
   2140 
   2141     if matches!(operation, ListingMutationOperation::Archive) {
   2142         canonical.listing.availability = Some(RadrootsListingAvailability::Status {
   2143             status: RadrootsListingStatus::Other {
   2144                 value: "archived".to_owned(),
   2145             },
   2146         });
   2147     }
   2148 
   2149     if config.output.dry_run {
   2150         validate_configured_listing_signer(config, &canonical)?;
   2151     }
   2152 
   2153     mutate_via_sdk_from_canonical(config, args, operation, canonical)
   2154 }
   2155 
   2156 fn mutate_via_sdk_from_canonical(
   2157     config: &RuntimeConfig,
   2158     args: &ListingMutationArgs,
   2159     operation: ListingMutationOperation,
   2160     canonical: CanonicalListingDraft,
   2161 ) -> Result<ListingMutationView, CliSdkAdapterError> {
   2162     let actor = RadrootsActorContext::local_account(
   2163         canonical.seller_pubkey.as_str(),
   2164         canonical.seller_account_id.clone(),
   2165         [RadrootsActorRole::Seller],
   2166     )
   2167     .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?;
   2168     let document = RadrootsListingDraftDocumentV1::new(canonical.listing.clone());
   2169     if config.output.dry_run {
   2170         let session = CliSdkSession::connect_memory(config)?;
   2171         let plan = session
   2172             .sdk()
   2173             .listings()
   2174             .prepare_publish(ListingPreparePublishRequest::from_document(actor, document))?;
   2175         return Ok(sdk_prepared_publish_view(
   2176             config, args, operation, &canonical, plan,
   2177         ));
   2178     }
   2179 
   2180     let session = CliSdkSession::connect_for_actor(
   2181         config,
   2182         Some(canonical.seller_account_id.as_str()),
   2183         canonical.seller_pubkey.as_str(),
   2184         "listing seller",
   2185     )?;
   2186     let mut request = ListingEnqueuePublishRequest::from_document(
   2187         actor,
   2188         document,
   2189         sdk_relay_target_policy(config),
   2190     );
   2191     if let Some(idempotency_key) = args.idempotency_key.as_deref() {
   2192         request = request.try_with_idempotency_key(idempotency_key)?;
   2193     }
   2194     let enqueue_receipt = session.block_on(session.sdk().listings().enqueue_publish(request))?;
   2195     let push_receipt = if args.offline {
   2196         None
   2197     } else {
   2198         Some(
   2199             session.block_on(
   2200                 session.sdk().sync().push_outbox(
   2201                     PushOutboxRequest::new()
   2202                         .with_limit(1)
   2203                         .with_relay_url_policy(sdk_relay_url_policy(config)),
   2204                 ),
   2205             )?,
   2206         )
   2207     };
   2208     Ok(sdk_enqueued_publish_view(
   2209         config,
   2210         args,
   2211         operation,
   2212         &canonical,
   2213         enqueue_receipt,
   2214         push_receipt,
   2215     ))
   2216 }
   2217 
   2218 fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> {
   2219     let toml = toml::to_string_pretty(draft).map_err(|error| {
   2220         RuntimeError::Config(format!("failed to render listing draft: {error}"))
   2221     })?;
   2222     Ok(format!(
   2223         "# radroots listing draft v1\n# this scaffold applies selected farm defaults and provided product inputs when available\n# review any remaining empty fields, then run `radroots listing validate <file>`\n\n{toml}"
   2224     ))
   2225 }
   2226 
   2227 fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> {
   2228     Ok(ListingValidationContext {
   2229         farm_setup_action: farm_setup_action(config)?,
   2230     })
   2231 }
   2232 
   2233 fn mutation_validation_context(
   2234     config: &RuntimeConfig,
   2235 ) -> Result<ListingValidationContext, RuntimeError> {
   2236     validation_context(config)
   2237 }
   2238 
   2239 fn canonicalize_draft(
   2240     draft: &ListingDraftDocument,
   2241     contents: &str,
   2242     _context: &ListingValidationContext,
   2243 ) -> Result<CanonicalListingDraft, ListingDraftValidationError> {
   2244     if draft.version != 1 {
   2245         return Err(issue_for_field(
   2246             contents,
   2247             "version",
   2248             format!("unsupported listing draft version `{}`", draft.version),
   2249         )
   2250         .into());
   2251     }
   2252     if draft.kind.trim() != DRAFT_KIND {
   2253         return Err(issue_for_field(
   2254             contents,
   2255             "kind",
   2256             format!("unsupported listing draft kind `{}`", draft.kind),
   2257         )
   2258         .into());
   2259     }
   2260 
   2261     let listing_id = draft.listing.d_tag.trim().to_owned();
   2262     if !is_d_tag_base64url(&listing_id) {
   2263         return Err(issue_for_field(
   2264             contents,
   2265             "listing.d_tag",
   2266             "listing d_tag must be a 22-character base64url identifier",
   2267         )
   2268         .into());
   2269     }
   2270 
   2271     let seller_account_id =
   2272         if let Some(account_id) = non_empty(draft.seller_actor.account_id.clone()) {
   2273             account_id
   2274         } else {
   2275             return Err(ListingDraftValidationError::MissingSellerAccount(
   2276                 issue_for_field(
   2277                     contents,
   2278                     "seller_actor.account_id",
   2279                     "missing listing seller_actor account_id",
   2280                 ),
   2281             ));
   2282         };
   2283 
   2284     let seller_pubkey = if let Some(pubkey) = non_empty(draft.seller_actor.pubkey.clone()) {
   2285         pubkey
   2286     } else {
   2287         return Err(ListingDraftValidationError::MissingSellerAccount(
   2288             issue_for_field(
   2289                 contents,
   2290                 "seller_actor.pubkey",
   2291                 "missing listing seller_actor pubkey",
   2292             ),
   2293         ));
   2294     };
   2295 
   2296     let seller_actor_source = if let Some(source) = non_empty(draft.seller_actor.source.clone()) {
   2297         source
   2298     } else {
   2299         return Err(ListingDraftValidationError::MissingSellerAccount(
   2300             issue_for_field(
   2301                 contents,
   2302                 "seller_actor.source",
   2303                 "missing listing seller_actor source",
   2304             ),
   2305         ));
   2306     };
   2307     if !matches!(
   2308         seller_actor_source.as_str(),
   2309         LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG
   2310             | LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT
   2311             | LISTING_SELLER_ACTOR_SOURCE_REBIND
   2312     ) {
   2313         return Err(issue_for_field(
   2314             contents,
   2315             "seller_actor.source",
   2316             format!("unsupported listing seller_actor source `{seller_actor_source}`"),
   2317         )
   2318         .into());
   2319     }
   2320 
   2321     let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) {
   2322         d_tag
   2323     } else {
   2324         return Err(
   2325             issue_for_field(contents, "listing.farm_d_tag", "missing listing farm_d_tag").into(),
   2326         );
   2327     };
   2328     if !is_d_tag_base64url(&farm_d_tag) {
   2329         return Err(issue_for_field(
   2330             contents,
   2331             "listing.farm_d_tag",
   2332             "farm_d_tag must be a 22-character base64url identifier",
   2333         )
   2334         .into());
   2335     }
   2336 
   2337     let quantity_amount = parse_decimal_field(
   2338         draft.primary_bin.quantity_amount.as_str(),
   2339         contents,
   2340         "primary_bin.quantity_amount",
   2341     )?;
   2342     let quantity_unit = parse_unit_field(
   2343         draft.primary_bin.quantity_unit.as_str(),
   2344         contents,
   2345         "primary_bin.quantity_unit",
   2346     )?;
   2347     let quantity = RadrootsCoreQuantity::new(quantity_amount, quantity_unit)
   2348         .with_optional_label(non_empty(draft.primary_bin.label.clone()))
   2349         .to_canonical()
   2350         .map_err(|error| {
   2351             issue_for_field(
   2352                 contents,
   2353                 "primary_bin.quantity_unit",
   2354                 format!("invalid primary_bin quantity unit conversion: {error}"),
   2355             )
   2356         })?;
   2357 
   2358     let price_amount = parse_decimal_field(
   2359         draft.primary_bin.price_amount.as_str(),
   2360         contents,
   2361         "primary_bin.price_amount",
   2362     )?;
   2363     let price_currency = parse_currency_field(
   2364         draft.primary_bin.price_currency.as_str(),
   2365         contents,
   2366         "primary_bin.price_currency",
   2367     )?;
   2368     let price_per_amount = parse_decimal_field(
   2369         draft.primary_bin.price_per_amount.as_str(),
   2370         contents,
   2371         "primary_bin.price_per_amount",
   2372     )?;
   2373     let price_per_unit = parse_unit_field(
   2374         draft.primary_bin.price_per_unit.as_str(),
   2375         contents,
   2376         "primary_bin.price_per_unit",
   2377     )?;
   2378     let price = RadrootsCoreQuantityPrice::new(
   2379         RadrootsCoreMoney::new(price_amount, price_currency),
   2380         RadrootsCoreQuantity::new(price_per_amount, price_per_unit),
   2381     )
   2382     .try_to_canonical_unit_price()
   2383     .map_err(|error| {
   2384         issue_for_field(
   2385             contents,
   2386             "primary_bin.price_per_unit",
   2387             format!("invalid primary_bin price definition: {error:?}"),
   2388         )
   2389     })?;
   2390 
   2391     let inventory_available = parse_decimal_field(
   2392         draft.inventory.available.as_str(),
   2393         contents,
   2394         "inventory.available",
   2395     )?;
   2396     let availability = build_availability(draft, contents)?;
   2397     let delivery_method = build_delivery_method(draft, contents)?;
   2398     let location = build_location(draft);
   2399     let discounts = build_listing_discounts(
   2400         draft,
   2401         contents,
   2402         draft.primary_bin.bin_id.trim(),
   2403         price_currency,
   2404     )?;
   2405     let primary_bin_id =
   2406         protocol_inventory_bin_id(draft.primary_bin.bin_id.trim(), "primary_bin.bin_id").map_err(
   2407             |error| {
   2408                 issue_for_field(
   2409                     contents,
   2410                     "primary_bin.bin_id",
   2411                     format!("invalid primary_bin bin id: {error}"),
   2412                 )
   2413             },
   2414         )?;
   2415 
   2416     let listing = RadrootsListing {
   2417         d_tag: protocol_d_tag(listing_id.as_str(), "listing d_tag").map_err(|error| {
   2418             issue_for_field(
   2419                 contents,
   2420                 "listing.d_tag",
   2421                 format!("invalid listing d_tag: {error}"),
   2422             )
   2423         })?,
   2424         published_at: None,
   2425         farm: RadrootsFarmRef {
   2426             pubkey: seller_pubkey.clone(),
   2427             d_tag: farm_d_tag.clone(),
   2428         },
   2429         product: RadrootsListingProduct {
   2430             key: draft.product.key.trim().to_owned(),
   2431             title: draft.product.title.trim().to_owned(),
   2432             category: draft.product.category.trim().to_owned(),
   2433             summary: non_empty(draft.product.summary.clone()),
   2434             process: None,
   2435             lot: None,
   2436             location: None,
   2437             profile: None,
   2438             year: None,
   2439         },
   2440         primary_bin_id: primary_bin_id.clone(),
   2441         bins: vec![RadrootsListingBin {
   2442             bin_id: primary_bin_id,
   2443             quantity,
   2444             price_per_canonical_unit: price,
   2445             display_amount: None,
   2446             display_unit: None,
   2447             display_label: non_empty(draft.primary_bin.label.clone()),
   2448             display_price: None,
   2449             display_price_unit: None,
   2450         }],
   2451         resource_area: None,
   2452         plot: None,
   2453         discounts,
   2454         inventory_available: Some(inventory_available),
   2455         availability: Some(availability),
   2456         delivery_method: Some(delivery_method),
   2457         location: Some(location),
   2458         images: None,
   2459     };
   2460 
   2461     Ok(CanonicalListingDraft {
   2462         listing_id,
   2463         seller_account_id,
   2464         seller_pubkey,
   2465         seller_actor_source,
   2466         farm_d_tag,
   2467         listing,
   2468     })
   2469 }
   2470 
   2471 fn build_availability(
   2472     draft: &ListingDraftDocument,
   2473     contents: &str,
   2474 ) -> Result<RadrootsListingAvailability, ListingValidationIssueView> {
   2475     let kind = if draft.availability.kind.trim().is_empty() {
   2476         if draft.availability.start.is_some() || draft.availability.end.is_some() {
   2477             "window"
   2478         } else {
   2479             "status"
   2480         }
   2481     } else {
   2482         draft.availability.kind.trim()
   2483     };
   2484 
   2485     match kind {
   2486         "status" => {
   2487             let status = draft.availability.status.trim();
   2488             if status.is_empty() {
   2489                 return Err(issue_for_field(
   2490                     contents,
   2491                     "availability.status",
   2492                     "missing availability status",
   2493                 ));
   2494             }
   2495             Ok(RadrootsListingAvailability::Status {
   2496                 status: match status {
   2497                     "active" => RadrootsListingStatus::Active,
   2498                     "sold" => RadrootsListingStatus::Sold,
   2499                     other => RadrootsListingStatus::Other {
   2500                         value: other.to_owned(),
   2501                     },
   2502                 },
   2503             })
   2504         }
   2505         "window" => Ok(RadrootsListingAvailability::Window {
   2506             start: draft.availability.start,
   2507             end: draft.availability.end,
   2508         }),
   2509         _ => Err(issue_for_field(
   2510             contents,
   2511             "availability.kind",
   2512             format!("unsupported availability kind `{kind}`"),
   2513         )),
   2514     }
   2515 }
   2516 
   2517 fn build_delivery_method(
   2518     draft: &ListingDraftDocument,
   2519     contents: &str,
   2520 ) -> Result<RadrootsListingDeliveryMethod, ListingValidationIssueView> {
   2521     let method = draft.delivery.method.trim();
   2522     if method.is_empty() {
   2523         return Err(issue_for_field(
   2524             contents,
   2525             "delivery.method",
   2526             "missing delivery method",
   2527         ));
   2528     }
   2529 
   2530     Ok(match method {
   2531         "pickup" => RadrootsListingDeliveryMethod::Pickup,
   2532         "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery,
   2533         "shipping" => RadrootsListingDeliveryMethod::Shipping,
   2534         other => RadrootsListingDeliveryMethod::Other {
   2535             method: other.to_owned(),
   2536         },
   2537     })
   2538 }
   2539 
   2540 fn build_location(draft: &ListingDraftDocument) -> RadrootsListingLocation {
   2541     RadrootsListingLocation {
   2542         primary: draft.location.primary.trim().to_owned(),
   2543         city: draft.location.city.clone().and_then(non_empty),
   2544         region: draft.location.region.clone().and_then(non_empty),
   2545         country: draft.location.country.clone().and_then(non_empty),
   2546         lat: None,
   2547         lng: None,
   2548         geohash: None,
   2549     }
   2550 }
   2551 
   2552 fn build_listing_discounts(
   2553     draft: &ListingDraftDocument,
   2554     contents: &str,
   2555     primary_bin_id: &str,
   2556     price_currency: RadrootsCoreCurrency,
   2557 ) -> Result<Option<Vec<RadrootsCoreDiscount>>, ListingValidationIssueView> {
   2558     let mut discounts = Vec::new();
   2559     for (index, discount) in draft.discounts.iter().enumerate() {
   2560         let field_prefix = format!("discounts.{index}");
   2561         if discount.id.trim().is_empty() {
   2562             return Err(issue_for_field(
   2563                 contents,
   2564                 field_prefix.as_str(),
   2565                 "discount id must not be empty",
   2566             ));
   2567         }
   2568         let bin_id = discount
   2569             .bin_id
   2570             .as_deref()
   2571             .map(str::trim)
   2572             .filter(|value| !value.is_empty())
   2573             .unwrap_or(primary_bin_id)
   2574             .to_owned();
   2575         let min = discount.min_bin_count.unwrap_or(1);
   2576         if min == 0 {
   2577             return Err(issue_for_field(
   2578                 contents,
   2579                 field_prefix.as_str(),
   2580                 "discount min_bin_count must be greater than zero",
   2581             ));
   2582         }
   2583         let value = match discount.kind.trim() {
   2584             "percent" => {
   2585                 let raw = discount.value.trim();
   2586                 if raw.is_empty() {
   2587                     return Err(issue_for_field(
   2588                         contents,
   2589                         field_prefix.as_str(),
   2590                         "percent discount requires value",
   2591                     ));
   2592                 }
   2593                 let percent = raw.parse::<RadrootsCorePercent>().map_err(|error| {
   2594                     issue_for_field(
   2595                         contents,
   2596                         field_prefix.as_str(),
   2597                         format!("percent discount value is invalid: {error}"),
   2598                     )
   2599                 })?;
   2600                 RadrootsCoreDiscountValue::Percent(percent)
   2601             }
   2602             "amount" => {
   2603                 let raw_amount = discount.amount.trim();
   2604                 if raw_amount.is_empty() {
   2605                     return Err(issue_for_field(
   2606                         contents,
   2607                         field_prefix.as_str(),
   2608                         "amount discount requires amount",
   2609                     ));
   2610                 }
   2611                 let amount = parse_decimal_field(raw_amount, contents, field_prefix.as_str())?;
   2612                 let currency = if discount.currency.trim().is_empty() {
   2613                     price_currency
   2614                 } else {
   2615                     parse_currency_field(
   2616                         discount.currency.as_str(),
   2617                         contents,
   2618                         field_prefix.as_str(),
   2619                     )?
   2620                 };
   2621                 RadrootsCoreDiscountValue::MoneyPerBin(RadrootsCoreMoney::new(amount, currency))
   2622             }
   2623             other => {
   2624                 return Err(issue_for_field(
   2625                     contents,
   2626                     field_prefix.as_str(),
   2627                     format!("unsupported discount kind `{other}`"),
   2628                 ));
   2629             }
   2630         };
   2631         let discount = RadrootsCoreDiscount {
   2632             scope: RadrootsCoreDiscountScope::Bin,
   2633             threshold: RadrootsCoreDiscountThreshold::BinCount { bin_id, min },
   2634             value,
   2635         };
   2636         if !discount.is_non_negative() {
   2637             return Err(issue_for_field(
   2638                 contents,
   2639                 field_prefix.as_str(),
   2640                 "discount value must not be negative",
   2641             ));
   2642         }
   2643         discounts.push(discount);
   2644     }
   2645     Ok((!discounts.is_empty()).then_some(discounts))
   2646 }
   2647 
   2648 fn listing_bound_account_issue(
   2649     config: &RuntimeConfig,
   2650     canonical: &CanonicalListingDraft,
   2651     contents: &str,
   2652 ) -> Result<Option<ListingValidationIssueView>, RuntimeError> {
   2653     let Some(account) = configured_account(config, &canonical.seller_account_id)? else {
   2654         return Ok(Some(issue_for_field(
   2655             contents,
   2656             "seller_actor.account_id",
   2657             format!(
   2658                 "listing seller_actor account_id `{}` is not present in the local account store",
   2659                 canonical.seller_account_id
   2660             ),
   2661         )));
   2662     };
   2663     let account_pubkey = account.record.public_identity.public_key_hex;
   2664     if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
   2665         return Ok(Some(issue_for_field(
   2666             contents,
   2667             "seller_actor.pubkey",
   2668             format!(
   2669                 "listing seller_actor pubkey `{}` does not match account `{}` pubkey `{account_pubkey}`",
   2670                 canonical.seller_pubkey, canonical.seller_account_id
   2671             ),
   2672         )));
   2673     }
   2674     Ok(None)
   2675 }
   2676 
   2677 fn ensure_listing_bound_account(
   2678     config: &RuntimeConfig,
   2679     canonical: &CanonicalListingDraft,
   2680     file: &Path,
   2681 ) -> Result<(), RuntimeError> {
   2682     validate_invocation_account_matches_bound(config, canonical, file)?;
   2683     let Some(account) = configured_account(config, &canonical.seller_account_id)? else {
   2684         return Err(account::AccountRuntimeFailure::unresolved_with_detail(
   2685             format!(
   2686                 "listing-bound seller account `{}` is not present in the local account store",
   2687                 canonical.seller_account_id
   2688             ),
   2689             json!({
   2690                 "seller_actor_source": canonical.seller_actor_source,
   2691                 "listing_seller_account_id": canonical.seller_account_id,
   2692                 "listing_file": file.display().to_string(),
   2693                 "actions": listing_bound_account_recovery_actions(file),
   2694             }),
   2695         )
   2696         .into());
   2697     };
   2698     let account_pubkey = account.record.public_identity.public_key_hex;
   2699     if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) {
   2700         return Err(account::AccountRuntimeFailure::mismatch_with_detail(
   2701             format!(
   2702                 "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`",
   2703                 canonical.seller_account_id, canonical.seller_pubkey
   2704             ),
   2705             json!({
   2706                 "seller_actor_source": canonical.seller_actor_source,
   2707                 "listing_seller_account_id": canonical.seller_account_id,
   2708                 "listing_seller_pubkey": canonical.seller_pubkey,
   2709                 "account_pubkey": account_pubkey,
   2710                 "listing_file": file.display().to_string(),
   2711                 "actions": listing_bound_account_recovery_actions(file),
   2712             }),
   2713         )
   2714         .into());
   2715     }
   2716     Ok(())
   2717 }
   2718 
   2719 fn validate_invocation_account_matches_bound(
   2720     config: &RuntimeConfig,
   2721     canonical: &CanonicalListingDraft,
   2722     file: &Path,
   2723 ) -> Result<(), RuntimeError> {
   2724     let Some(selector) = config
   2725         .account
   2726         .selector
   2727         .as_deref()
   2728         .map(str::trim)
   2729         .filter(|selector| !selector.is_empty())
   2730     else {
   2731         return Ok(());
   2732     };
   2733     let attempted = account::resolve_account_selector(config, selector)?;
   2734     if attempted.record.account_id.to_string() == canonical.seller_account_id {
   2735         return Ok(());
   2736     }
   2737     Err(account::AccountRuntimeFailure::mismatch_with_detail(
   2738         format!(
   2739             "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`",
   2740             canonical.seller_account_id, attempted.record.account_id
   2741         ),
   2742         json!({
   2743             "seller_actor_source": canonical.seller_actor_source,
   2744             "listing_seller_account_id": canonical.seller_account_id,
   2745             "attempted_seller_account_id": attempted.record.account_id.to_string(),
   2746             "listing_file": file.display().to_string(),
   2747             "actions": listing_bound_account_recovery_actions(file),
   2748         }),
   2749     )
   2750     .into())
   2751 }
   2752 
   2753 fn listing_bound_account_recovery_actions(file: &Path) -> Vec<String> {
   2754     vec![
   2755         "radroots account import <path>".to_owned(),
   2756         format!("radroots listing rebind {} <selector>", file.display()),
   2757     ]
   2758 }
   2759 
   2760 fn invalid_validation_view(
   2761     file: &Path,
   2762     draft: &ListingDraftDocument,
   2763     context: &ListingValidationContext,
   2764     issue: ListingValidationIssueView,
   2765 ) -> ListingValidateView {
   2766     let mut actions = vec![format!("edit {}", file.display())];
   2767     if draft.seller_actor.account_id.trim().is_empty() {
   2768         actions.push("radroots account create".to_owned());
   2769     } else {
   2770         actions.push(format!(
   2771             "radroots listing rebind {} <selector>",
   2772             file.display()
   2773         ));
   2774     }
   2775     if draft.listing.farm_d_tag.trim().is_empty() {
   2776         actions.push(context.farm_setup_action.clone());
   2777     }
   2778 
   2779     ListingValidateView {
   2780         state: "invalid".to_owned(),
   2781         source: LISTING_SOURCE.to_owned(),
   2782         file: file.display().to_string(),
   2783         valid: false,
   2784         listing_id: non_empty(draft.listing.d_tag.clone()),
   2785         seller_account_id: non_empty(draft.seller_actor.account_id.clone()),
   2786         seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()),
   2787         seller_actor_source: non_empty(draft.seller_actor.source.clone()),
   2788         farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()),
   2789         issues: vec![issue],
   2790         actions,
   2791     }
   2792 }
   2793 
   2794 fn validate_configured_listing_signer(
   2795     config: &RuntimeConfig,
   2796     canonical: &CanonicalListingDraft,
   2797 ) -> Result<(), RuntimeError> {
   2798     validate_configured_signer_for_actor(
   2799         config,
   2800         Some(canonical.seller_account_id.as_str()),
   2801         canonical.seller_pubkey.as_str(),
   2802         "listing seller",
   2803     )
   2804 }
   2805 
   2806 fn issue_from_trade_validation(
   2807     error: RadrootsTradeValidationListingError,
   2808     contents: &str,
   2809 ) -> ListingValidationIssueView {
   2810     match error {
   2811         RadrootsTradeValidationListingError::InvalidSeller => issue_for_field(
   2812             contents,
   2813             "seller_actor.pubkey",
   2814             "listing author does not match the farm pubkey",
   2815         ),
   2816         RadrootsTradeValidationListingError::MissingTitle => {
   2817             issue_for_field(contents, "product.title", "missing listing title")
   2818         }
   2819         RadrootsTradeValidationListingError::MissingDescription => {
   2820             issue_for_field(contents, "product.summary", "missing listing description")
   2821         }
   2822         RadrootsTradeValidationListingError::MissingProductType => {
   2823             issue_for_field(contents, "product.category", "missing listing product type")
   2824         }
   2825         RadrootsTradeValidationListingError::MissingBins
   2826         | RadrootsTradeValidationListingError::MissingPrimaryBin
   2827         | RadrootsTradeValidationListingError::InvalidBin => {
   2828             issue_for_field(contents, "primary_bin.bin_id", error.to_string())
   2829         }
   2830         RadrootsTradeValidationListingError::InvalidPrice => issue_for_field(
   2831             contents,
   2832             "primary_bin.price_amount",
   2833             "invalid listing price",
   2834         ),
   2835         RadrootsTradeValidationListingError::MissingInventory
   2836         | RadrootsTradeValidationListingError::InvalidInventory => {
   2837             issue_for_field(contents, "inventory.available", error.to_string())
   2838         }
   2839         RadrootsTradeValidationListingError::MissingAvailability => issue_for_field(
   2840             contents,
   2841             "availability.status",
   2842             "missing listing availability",
   2843         ),
   2844         RadrootsTradeValidationListingError::MissingLocation => {
   2845             issue_for_field(contents, "location.primary", "missing listing location")
   2846         }
   2847         RadrootsTradeValidationListingError::MissingDeliveryMethod => issue_for_field(
   2848             contents,
   2849             "delivery.method",
   2850             "missing listing delivery method",
   2851         ),
   2852         other => issue_for_field(contents, "listing", other.to_string()),
   2853     }
   2854 }
   2855 
   2856 fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> {
   2857     let account_resolution = account::resolve_account_resolution(config)?;
   2858     let Some(selected_account) = account_resolution.resolved_account.clone() else {
   2859         return Err(account::AccountRuntimeFailure::unresolved_with_detail(
   2860             "no resolved account is available for listing seller actor",
   2861             json!({
   2862                 "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT,
   2863                 "actions": [
   2864                     "radroots account create",
   2865                     "radroots account import <path>",
   2866                 ],
   2867             }),
   2868         )
   2869         .into());
   2870     };
   2871     let mut defaults = ListingAuthoringDefaults {
   2872         farm_config_present: false,
   2873         farm_defaults_ready: false,
   2874         farm_next_action: Some(farm_setup_action(config)?),
   2875         farm_reason: Some(
   2876             "selected farm draft not found; delivery, location, and farm defaults were left blank"
   2877                 .to_owned(),
   2878         ),
   2879         farm_name: None,
   2880         seller_account_id: selected_account.record.account_id.to_string(),
   2881         seller_pubkey: selected_account
   2882             .record
   2883             .public_identity
   2884             .public_key_hex
   2885             .clone(),
   2886         seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
   2887         selected_farm_d_tag: None,
   2888         delivery_method: None,
   2889         location: None,
   2890     };
   2891 
   2892     let Some(resolved) = farm_config::load(config, None)? else {
   2893         return Ok(defaults);
   2894     };
   2895     let Some(account) = configured_account(config, &resolved.document.selection.account)? else {
   2896         let account_id = resolved.document.selection.account.clone();
   2897         return Err(account::AccountRuntimeFailure::unresolved_with_detail(
   2898             format!(
   2899                 "farm-bound seller account `{account_id}` is not present in the local account store"
   2900             ),
   2901             json!({
   2902                 "seller_actor_source": "farm_config",
   2903                 "farm_bound_seller_account_id": account_id,
   2904                 "actions": [
   2905                     "radroots account import <path>",
   2906                     "radroots farm rebind <selector>",
   2907                 ],
   2908             }),
   2909         )
   2910         .into());
   2911     };
   2912 
   2913     defaults.farm_config_present = true;
   2914     defaults.farm_name = resolved
   2915         .document
   2916         .profile
   2917         .display_name
   2918         .clone()
   2919         .and_then(non_empty)
   2920         .or_else(|| non_empty(resolved.document.profile.name.clone()))
   2921         .or_else(|| non_empty(resolved.document.farm.name.clone()));
   2922     defaults.seller_account_id = resolved.document.selection.account.clone();
   2923     defaults.seller_pubkey = account.record.public_identity.public_key_hex.clone();
   2924     defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned();
   2925     defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone());
   2926     let draft_missing = farm_config::missing_fields(&resolved.document);
   2927     defaults.farm_defaults_ready = !draft_missing.iter().any(|field| {
   2928         matches!(
   2929             field,
   2930             farm_config::FarmMissingField::Location | farm_config::FarmMissingField::Delivery
   2931         )
   2932     });
   2933     if defaults.farm_defaults_ready {
   2934         defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone());
   2935         defaults.location = Some(draft_location_from_model(
   2936             &resolved.document.listing_defaults.location,
   2937         ));
   2938         defaults.farm_next_action = None;
   2939         defaults.farm_reason = None;
   2940     } else {
   2941         defaults.farm_next_action = Some("radroots farm readiness check".to_owned());
   2942         defaults.farm_reason = Some(
   2943             "selected farm draft is missing delivery or location defaults; those fields were left blank"
   2944                 .to_owned(),
   2945         );
   2946     }
   2947     Ok(defaults)
   2948 }
   2949 
   2950 fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraftLocation {
   2951     ListingDraftLocation {
   2952         primary: location.primary.clone(),
   2953         city: location.city.clone(),
   2954         region: location.region.clone(),
   2955         country: location.country.clone(),
   2956     }
   2957 }
   2958 
   2959 fn farm_setup_action(_config: &RuntimeConfig) -> Result<String, RuntimeError> {
   2960     Ok("radroots farm create".to_owned())
   2961 }
   2962 
   2963 fn drafts_dir(config: &RuntimeConfig) -> PathBuf {
   2964     config.paths.app_data_root.join(LISTING_DRAFTS_DIR)
   2965 }
   2966 
   2967 fn file_stem(path: &Path) -> String {
   2968     path.file_stem()
   2969         .and_then(|value| value.to_str())
   2970         .unwrap_or("unknown")
   2971         .to_owned()
   2972 }
   2973 
   2974 fn modified_unix(path: &Path) -> Option<u64> {
   2975     let modified = fs::metadata(path).ok()?.modified().ok()?;
   2976     modified
   2977         .duration_since(UNIX_EPOCH)
   2978         .ok()
   2979         .map(|value| value.as_secs())
   2980 }
   2981 
   2982 fn configured_account(
   2983     config: &RuntimeConfig,
   2984     account_id: &str,
   2985 ) -> Result<Option<account::AccountRecordView>, RuntimeError> {
   2986     let snapshot = account::snapshot(config)?;
   2987     Ok(snapshot
   2988         .accounts
   2989         .into_iter()
   2990         .find(|account| account.record.account_id.as_str() == account_id))
   2991 }
   2992 
   2993 fn parse_decimal_field(
   2994     value: &str,
   2995     contents: &str,
   2996     field: &str,
   2997 ) -> Result<RadrootsCoreDecimal, ListingValidationIssueView> {
   2998     value.trim().parse::<RadrootsCoreDecimal>().map_err(|_| {
   2999         issue_for_field(
   3000             contents,
   3001             field,
   3002             format!("`{field}` must be a valid decimal value"),
   3003         )
   3004     })
   3005 }
   3006 
   3007 fn parse_unit_field(
   3008     value: &str,
   3009     contents: &str,
   3010     field: &str,
   3011 ) -> Result<RadrootsCoreUnit, ListingValidationIssueView> {
   3012     value.parse::<RadrootsCoreUnit>().map_err(|_| {
   3013         issue_for_field(
   3014             contents,
   3015             field,
   3016             format!("`{field}` must be a valid unit code"),
   3017         )
   3018     })
   3019 }
   3020 
   3021 fn parse_currency_field(
   3022     value: &str,
   3023     contents: &str,
   3024     field: &str,
   3025 ) -> Result<RadrootsCoreCurrency, ListingValidationIssueView> {
   3026     let upper = value.trim().to_ascii_uppercase();
   3027     RadrootsCoreCurrency::from_str_upper(&upper).map_err(|_| {
   3028         issue_for_field(
   3029             contents,
   3030             field,
   3031             format!("`{field}` must be a valid ISO currency code"),
   3032         )
   3033     })
   3034 }
   3035 
   3036 fn issue_for_field(
   3037     contents: &str,
   3038     field: &str,
   3039     message: impl Into<String>,
   3040 ) -> ListingValidationIssueView {
   3041     ListingValidationIssueView {
   3042         field: field.to_owned(),
   3043         message: message.into(),
   3044         line: line_for_field(contents, field),
   3045     }
   3046 }
   3047 
   3048 fn line_for_field(contents: &str, field: &str) -> Option<usize> {
   3049     let needles: &[&str] = match field {
   3050         "version" => &["version ="],
   3051         "kind" => &["kind ="],
   3052         "listing.d_tag" => &["d_tag ="],
   3053         "listing.farm_d_tag" => &["farm_d_tag ="],
   3054         "seller_actor.account_id" => &["[seller_actor]", "account_id ="],
   3055         "seller_actor.pubkey" => &["[seller_actor]", "pubkey ="],
   3056         "seller_actor.source" => &["[seller_actor]", "source ="],
   3057         "product.key" => &["key ="],
   3058         "product.title" => &["title ="],
   3059         "product.category" => &["category ="],
   3060         "product.summary" => &["summary ="],
   3061         "primary_bin.bin_id" => &["bin_id ="],
   3062         "primary_bin.quantity_amount" => &["quantity_amount ="],
   3063         "primary_bin.quantity_unit" => &["quantity_unit ="],
   3064         "primary_bin.price_amount" => &["price_amount ="],
   3065         "primary_bin.price_currency" => &["price_currency ="],
   3066         "primary_bin.price_per_amount" => &["price_per_amount ="],
   3067         "primary_bin.price_per_unit" => &["price_per_unit ="],
   3068         "inventory.available" => &["available ="],
   3069         "availability.kind" => &["[availability]", "kind ="],
   3070         "availability.status" => &["status ="],
   3071         "delivery.method" => &["method ="],
   3072         "location.primary" => &["primary ="],
   3073         field if field.starts_with("discounts.") => &["[[discounts]]"],
   3074         _ => &[],
   3075     };
   3076     for needle in needles {
   3077         if let Some(line) = contents.lines().position(|line| line.contains(needle)) {
   3078             return Some(line + 1);
   3079         }
   3080     }
   3081     None
   3082 }
   3083 
   3084 fn line_for_offset(contents: &str, offset: usize) -> usize {
   3085     let mut seen = 0usize;
   3086     for (index, line) in contents.lines().enumerate() {
   3087         seen += line.len() + 1;
   3088         if seen >= offset {
   3089             return index + 1;
   3090         }
   3091     }
   3092     contents.lines().count().max(1)
   3093 }
   3094 
   3095 fn non_empty(value: String) -> Option<String> {
   3096     let trimmed = value.trim();
   3097     if trimmed.is_empty() {
   3098         None
   3099     } else {
   3100         Some(trimmed.to_owned())
   3101     }
   3102 }
   3103 
   3104 fn generate_d_tag() -> String {
   3105     let nanos = SystemTime::now()
   3106         .duration_since(UNIX_EPOCH)
   3107         .map(|duration| duration.as_nanos())
   3108         .unwrap_or_default();
   3109     let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
   3110     let mixed = nanos ^ counter;
   3111     encode_base64url_no_pad(mixed.to_be_bytes())
   3112 }
   3113 
   3114 fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
   3115     const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
   3116     let mut output = String::with_capacity(22);
   3117     let mut index = 0usize;
   3118     while index + 3 <= bytes.len() {
   3119         let block = ((bytes[index] as u32) << 16)
   3120             | ((bytes[index + 1] as u32) << 8)
   3121             | (bytes[index + 2] as u32);
   3122         output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
   3123         output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
   3124         output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
   3125         output.push(ALPHABET[(block & 0x3f) as usize] as char);
   3126         index += 3;
   3127     }
   3128     let remaining = bytes.len() - index;
   3129     if remaining == 1 {
   3130         let block = (bytes[index] as u32) << 16;
   3131         output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
   3132         output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
   3133     } else if remaining == 2 {
   3134         let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8);
   3135         output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
   3136         output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
   3137         output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
   3138     }
   3139     output
   3140 }
   3141 
   3142 #[cfg(test)]
   3143 mod tests {
   3144     use super::{
   3145         DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag,
   3146         sdk_publish_actions, sdk_publish_reason, sdk_publish_state, sdk_push_acknowledged_relays,
   3147         sdk_push_failed_relays,
   3148     };
   3149     use crate::cli::global::ListingMutationArgs;
   3150     use radroots_events::ids::RadrootsEventId;
   3151     use radroots_events_codec::d_tag::is_d_tag_base64url;
   3152     use radroots_sdk::{
   3153         PushOutboxEventReceipt, PushOutboxEventState, PushOutboxRelayOutcomeKind,
   3154         PushOutboxRelayReceipt,
   3155     };
   3156 
   3157     #[test]
   3158     fn generated_listing_d_tag_is_valid_base64url() {
   3159         let d_tag = generate_d_tag();
   3160         assert!(is_d_tag_base64url(&d_tag));
   3161     }
   3162 
   3163     #[test]
   3164     fn base64url_encoder_produces_twenty_two_characters_for_sixteen_bytes() {
   3165         let encoded = encode_base64url_no_pad([0u8; 16]);
   3166         assert_eq!(encoded.len(), 22);
   3167         assert!(is_d_tag_base64url(&encoded));
   3168     }
   3169 
   3170     #[test]
   3171     fn sdk_push_receipt_helpers_map_published_and_auth_required_states() {
   3172         let accepted = sdk_push_event(
   3173             PushOutboxEventState::Published,
   3174             PushOutboxRelayOutcomeKind::Accepted,
   3175             Some("accepted".to_owned()),
   3176         );
   3177         let args = listing_mutation_args(false);
   3178 
   3179         assert_eq!(sdk_publish_state(&args, Some(&accepted)), "published");
   3180         assert!(sdk_publish_reason(&args, Some(&accepted)).is_none());
   3181         assert!(sdk_publish_actions(&args, Some(&accepted)).is_empty());
   3182         assert_eq!(
   3183             sdk_push_acknowledged_relays(&accepted),
   3184             vec!["ws://127.0.0.1:19000".to_owned()]
   3185         );
   3186         assert!(sdk_push_failed_relays(&accepted).is_empty());
   3187 
   3188         let auth_required = sdk_push_event(
   3189             PushOutboxEventState::PublishRetryable,
   3190             PushOutboxRelayOutcomeKind::AuthRequired,
   3191             Some("auth required".to_owned()),
   3192         );
   3193         let failed = sdk_push_failed_relays(&auth_required);
   3194 
   3195         assert_eq!(
   3196             sdk_publish_state(&args, Some(&auth_required)),
   3197             "unavailable"
   3198         );
   3199         assert!(
   3200             sdk_publish_reason(&args, Some(&auth_required))
   3201                 .expect("retry reason")
   3202                 .contains("accepted quorum")
   3203         );
   3204         assert_eq!(failed.len(), 1);
   3205         assert_eq!(failed[0].relay, "ws://127.0.0.1:19000");
   3206         assert_eq!(failed[0].reason, "auth required");
   3207         assert_eq!(
   3208             sdk_publish_actions(&args, Some(&auth_required)),
   3209             vec!["radroots sync push".to_owned()]
   3210         );
   3211     }
   3212 
   3213     #[test]
   3214     fn listing_draft_kind_constant_is_stable() {
   3215         let document = ListingDraftDocument {
   3216             version: 1,
   3217             kind: DRAFT_KIND.to_owned(),
   3218             listing: super::ListingDraftMeta {
   3219                 d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
   3220                 farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(),
   3221             },
   3222             seller_actor: super::ListingDraftSellerActor {
   3223                 account_id: "acct_seller".to_owned(),
   3224                 pubkey: "a".repeat(64),
   3225                 source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
   3226             },
   3227             product: super::ListingDraftProduct {
   3228                 key: "sku".to_owned(),
   3229                 title: "Widget".to_owned(),
   3230                 category: "produce".to_owned(),
   3231                 summary: "Fresh".to_owned(),
   3232             },
   3233             primary_bin: super::ListingDraftPrimaryBin {
   3234                 bin_id: "bin-1".to_owned(),
   3235                 quantity_amount: "1".to_owned(),
   3236                 quantity_unit: "kg".to_owned(),
   3237                 price_amount: "12.50".to_owned(),
   3238                 price_currency: "USD".to_owned(),
   3239                 price_per_amount: "1".to_owned(),
   3240                 price_per_unit: "kg".to_owned(),
   3241                 label: "kg".to_owned(),
   3242             },
   3243             inventory: super::ListingDraftInventory {
   3244                 available: "2".to_owned(),
   3245             },
   3246             availability: super::ListingDraftAvailability {
   3247                 kind: "status".to_owned(),
   3248                 status: "active".to_owned(),
   3249                 start: None,
   3250                 end: None,
   3251             },
   3252             delivery: super::ListingDraftDelivery {
   3253                 method: "pickup".to_owned(),
   3254             },
   3255             location: super::ListingDraftLocation {
   3256                 primary: "Asheville".to_owned(),
   3257                 city: None,
   3258                 region: None,
   3259                 country: None,
   3260             },
   3261             discounts: Vec::new(),
   3262         };
   3263         let rendered = toml::to_string_pretty(&document).expect("render draft");
   3264         assert!(rendered.contains("kind = \"listing_draft_v1\""));
   3265     }
   3266 
   3267     #[test]
   3268     fn listing_draft_canonicalization_preserves_discounts() {
   3269         let seller_pubkey = "a".repeat(64);
   3270         let document = ListingDraftDocument {
   3271             version: 1,
   3272             kind: DRAFT_KIND.to_owned(),
   3273             listing: super::ListingDraftMeta {
   3274                 d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
   3275                 farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(),
   3276             },
   3277             seller_actor: super::ListingDraftSellerActor {
   3278                 account_id: "acct_seller".to_owned(),
   3279                 pubkey: seller_pubkey.clone(),
   3280                 source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(),
   3281             },
   3282             product: super::ListingDraftProduct {
   3283                 key: "sku".to_owned(),
   3284                 title: "Widget".to_owned(),
   3285                 category: "produce".to_owned(),
   3286                 summary: "Fresh".to_owned(),
   3287             },
   3288             primary_bin: super::ListingDraftPrimaryBin {
   3289                 bin_id: "bin-1".to_owned(),
   3290                 quantity_amount: "1".to_owned(),
   3291                 quantity_unit: "each".to_owned(),
   3292                 price_amount: "10".to_owned(),
   3293                 price_currency: "USD".to_owned(),
   3294                 price_per_amount: "1".to_owned(),
   3295                 price_per_unit: "each".to_owned(),
   3296                 label: "each".to_owned(),
   3297             },
   3298             inventory: super::ListingDraftInventory {
   3299                 available: "2".to_owned(),
   3300             },
   3301             availability: super::ListingDraftAvailability {
   3302                 kind: "status".to_owned(),
   3303                 status: "active".to_owned(),
   3304                 start: None,
   3305                 end: None,
   3306             },
   3307             delivery: super::ListingDraftDelivery {
   3308                 method: "pickup".to_owned(),
   3309             },
   3310             location: super::ListingDraftLocation {
   3311                 primary: "Asheville".to_owned(),
   3312                 city: None,
   3313                 region: None,
   3314                 country: None,
   3315             },
   3316             discounts: vec![super::ListingDraftDiscount {
   3317                 id: "discount_farmstand".to_owned(),
   3318                 label: "farmstand pickup".to_owned(),
   3319                 kind: "percent".to_owned(),
   3320                 value: "10".to_owned(),
   3321                 amount: String::new(),
   3322                 currency: String::new(),
   3323                 bin_id: None,
   3324                 min_bin_count: None,
   3325             }],
   3326         };
   3327         let contents = toml::to_string_pretty(&document).expect("render draft");
   3328         let context = super::ListingValidationContext {
   3329             farm_setup_action: "radroots farm create".to_owned(),
   3330         };
   3331 
   3332         let canonical =
   3333             super::canonicalize_draft(&document, contents.as_str(), &context).expect("canonical");
   3334 
   3335         assert!(contents.contains("[[discounts]]"));
   3336         assert_eq!(
   3337             canonical
   3338                 .listing
   3339                 .discounts
   3340                 .as_ref()
   3341                 .expect("discounts")
   3342                 .len(),
   3343             1
   3344         );
   3345     }
   3346 
   3347     fn sdk_push_event(
   3348         final_state: PushOutboxEventState,
   3349         outcome_kind: PushOutboxRelayOutcomeKind,
   3350         message: Option<String>,
   3351     ) -> PushOutboxEventReceipt {
   3352         PushOutboxEventReceipt {
   3353             event_id: RadrootsEventId::parse("e".repeat(64)).expect("event id"),
   3354             outbox_event_id: 7,
   3355             final_state,
   3356             attempted_count: 1,
   3357             accepted_count: usize::from(matches!(
   3358                 outcome_kind,
   3359                 PushOutboxRelayOutcomeKind::Accepted
   3360                     | PushOutboxRelayOutcomeKind::DuplicateAccepted
   3361             )),
   3362             retryable_count: usize::from(matches!(
   3363                 outcome_kind,
   3364                 PushOutboxRelayOutcomeKind::AuthRequired
   3365                     | PushOutboxRelayOutcomeKind::Timeout
   3366                     | PushOutboxRelayOutcomeKind::ConnectionFailed
   3367             )),
   3368             terminal_count: 0,
   3369             quorum: 1,
   3370             quorum_met: matches!(
   3371                 outcome_kind,
   3372                 PushOutboxRelayOutcomeKind::Accepted
   3373                     | PushOutboxRelayOutcomeKind::DuplicateAccepted
   3374             ),
   3375             relays: vec![PushOutboxRelayReceipt {
   3376                 relay_url: "ws://127.0.0.1:19000".to_owned(),
   3377                 outcome_kind,
   3378                 attempted: true,
   3379                 message,
   3380             }],
   3381         }
   3382     }
   3383 
   3384     fn listing_mutation_args(offline: bool) -> ListingMutationArgs {
   3385         ListingMutationArgs {
   3386             file: "listing.toml".into(),
   3387             idempotency_key: None,
   3388             print_event: false,
   3389             offline,
   3390         }
   3391     }
   3392 }