cli

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

commit d51fbded21c5663d58b9768985fba147d227783e
parent 5399882fe1e6070c061849730d6a9e233292a6d4
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 01:54:20 +0000

cli: rewire listing authoring to farm config

Diffstat:
Msrc/cli.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/commands/listing.rs | 20++++++++++++++++++--
Msrc/domain/runtime.rs | 16++++++++++++++++
Msrc/render/mod.rs | 51++++++++++++++++++++++++++++++++-------------------
Msrc/runtime/listing.rs | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mtests/listing.rs | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
6 files changed, 397 insertions(+), 142 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -436,6 +436,32 @@ pub enum ListingCommand { pub struct ListingNewArgs { #[arg(long)] pub output: Option<PathBuf>, + #[arg(long)] + pub key: Option<String>, + #[arg(long)] + pub title: Option<String>, + #[arg(long)] + pub category: Option<String>, + #[arg(long)] + pub summary: Option<String>, + #[arg(long = "bin-id")] + pub bin_id: Option<String>, + #[arg(long = "quantity-amount")] + pub quantity_amount: Option<String>, + #[arg(long = "quantity-unit")] + pub quantity_unit: Option<String>, + #[arg(long = "price-amount")] + pub price_amount: Option<String>, + #[arg(long = "price-currency")] + pub price_currency: Option<String>, + #[arg(long = "price-per-amount")] + pub price_per_amount: Option<String>, + #[arg(long = "price-per-unit")] + pub price_per_unit: Option<String>, + #[arg(long)] + pub available: Option<String>, + #[arg(long)] + pub label: Option<String>, } #[derive(Debug, Clone, Args)] @@ -916,10 +942,48 @@ mod tests { _ => panic!("unexpected command variant"), } - let listing_new = CliArgs::parse_from(["radroots", "listing", "new"]); + let listing_new = CliArgs::parse_from([ + "radroots", + "listing", + "new", + "--output", + "draft.toml", + "--key", + "sf-tomatoes", + "--title", + "San Francisco Tomatoes", + "--category", + "produce.vegetables.tomatoes", + "--summary", + "Fresh tomatoes", + "--quantity-amount", + "1000", + "--quantity-unit", + "g", + "--price-amount", + "0.01", + "--available", + "25", + ]); match listing_new.command { Command::Listing(args) => match args.command { - ListingCommand::New(new) => assert!(new.output.is_none()), + ListingCommand::New(new) => { + assert_eq!( + new.output.as_deref().and_then(|path| path.to_str()), + Some("draft.toml") + ); + assert_eq!(new.key.as_deref(), Some("sf-tomatoes")); + assert_eq!(new.title.as_deref(), Some("San Francisco Tomatoes")); + assert_eq!(new.category.as_deref(), Some("produce.vegetables.tomatoes")); + assert_eq!(new.summary.as_deref(), Some("Fresh tomatoes")); + assert_eq!(new.quantity_amount.as_deref(), Some("1000")); + assert_eq!(new.quantity_unit.as_deref(), Some("g")); + assert_eq!(new.price_amount.as_deref(), Some("0.01")); + assert_eq!(new.available.as_deref(), Some("25")); + assert!(new.price_currency.is_none()); + assert!(new.price_per_amount.is_none()); + assert!(new.price_per_unit.is_none()); + } _ => panic!("unexpected listing subcommand"), }, _ => panic!("unexpected command variant"), @@ -1221,21 +1285,15 @@ mod tests { #[test] fn command_contract_helpers_report_supported_modes() { let config_show = CliArgs::parse_from(["radroots", "config", "show"]); - assert!( - config_show - .command - .supports_output_format(OutputFormat::Human) - ); - assert!( - config_show - .command - .supports_output_format(OutputFormat::Json) - ); - assert!( - !config_show - .command - .supports_output_format(OutputFormat::Ndjson) - ); + assert!(config_show + .command + .supports_output_format(OutputFormat::Human)); + assert!(config_show + .command + .supports_output_format(OutputFormat::Json)); + assert!(!config_show + .command + .supports_output_format(OutputFormat::Ndjson)); assert!(config_show.command.supports_dry_run()); let account_new = CliArgs::parse_from(["radroots", "account", "new"]); @@ -1257,11 +1315,9 @@ mod tests { let farm_status = CliArgs::parse_from(["radroots", "farm", "status"]); assert_eq!(farm_status.command.display_name(), "farm status"); assert!(farm_status.command.supports_dry_run()); - assert!( - !farm_status - .command - .supports_output_format(OutputFormat::Ndjson) - ); + assert!(!farm_status + .command + .supports_output_format(OutputFormat::Ndjson)); let farm_publish = CliArgs::parse_from(["radroots", "farm", "publish"]); assert_eq!(farm_publish.command.display_name(), "farm publish"); @@ -1271,18 +1327,14 @@ mod tests { assert!(find.command.supports_output_format(OutputFormat::Ndjson)); let sync_watch = CliArgs::parse_from(["radroots", "sync", "watch", "--frames", "1"]); - assert!( - sync_watch - .command - .supports_output_format(OutputFormat::Ndjson) - ); + assert!(sync_watch + .command + .supports_output_format(OutputFormat::Ndjson)); let order_watch = CliArgs::parse_from(["radroots", "order", "watch", "ord_demo"]); - assert!( - order_watch - .command - .supports_output_format(OutputFormat::Ndjson) - ); + assert!(order_watch + .command + .supports_output_format(OutputFormat::Ndjson)); let order_submit = CliArgs::parse_from(["radroots", "order", "submit", "ord_demo"]); assert_eq!(order_submit.command.display_name(), "order submit"); @@ -1291,10 +1343,8 @@ mod tests { let runtime_status = CliArgs::parse_from(["radroots", "runtime", "status", "radrootsd"]); assert_eq!(runtime_status.command.display_name(), "runtime status"); assert!(runtime_status.command.supports_dry_run()); - assert!( - !runtime_status - .command - .supports_output_format(OutputFormat::Ndjson) - ); + assert!(!runtime_status + .command + .supports_output_format(OutputFormat::Ndjson)); } } diff --git a/src/commands/listing.rs b/src/commands/listing.rs @@ -1,11 +1,27 @@ use crate::cli::{ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs}; use crate::domain::runtime::{CommandOutput, CommandView}; -use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; +use crate::runtime::RuntimeError; pub fn new(config: &RuntimeConfig, args: &ListingNewArgs) -> Result<CommandOutput, RuntimeError> { let view = crate::runtime::listing::scaffold(config, args)?; - Ok(CommandOutput::success(CommandView::ListingNew(view))) + Ok(match view.disposition() { + crate::domain::runtime::CommandDisposition::Success => { + CommandOutput::success(CommandView::ListingNew(view)) + } + crate::domain::runtime::CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::ListingNew(view)) + } + crate::domain::runtime::CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::ListingNew(view)) + } + crate::domain::runtime::CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::ListingNew(view)) + } + crate::domain::runtime::CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::ListingNew(view)) + } + }) } pub fn validate( diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1321,10 +1321,26 @@ pub struct ListingNewView { pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub farm_d_tag: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery_method: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_primary: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, } +impl ListingNewView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct ListingValidateView { pub state: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -11,8 +11,8 @@ use crate::domain::runtime::{ RuntimeActionView, RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView, }; -use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; +use crate::runtime::RuntimeError; const THIN_RULE: &str = "────────────────────────────────────────────────────"; @@ -496,11 +496,19 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<() } fn yes_no(value: bool) -> &'static str { - if value { "yes" } else { "no" } + if value { + "yes" + } else { + "no" + } } fn present_absent(value: bool) -> &'static str { - if value { "present" } else { "absent" } + if value { + "present" + } else { + "absent" + } } fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result<(), RuntimeError> { @@ -1724,7 +1732,16 @@ fn render_listing_new(stdout: &mut dyn Write, view: &ListingNewView) -> Result<( if let Some(farm_d_tag) = &view.farm_d_tag { rows.push(("farm d_tag", farm_d_tag.as_str())); } + if let Some(delivery_method) = &view.delivery_method { + rows.push(("delivery", delivery_method.as_str())); + } + if let Some(location_primary) = &view.location_primary { + rows.push(("location", location_primary.as_str())); + } render_pairs(stdout, "draft", rows.as_slice())?; + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } writeln!(stdout, "source: {}", view.source)?; render_actions(stdout, &view.actions)?; Ok(()) @@ -2815,7 +2832,7 @@ fn human_command_name(view: &CommandView) -> &'static str { #[cfg(test)] mod tests { - use super::{Table, render_human_to, render_ndjson_to, render_table}; + use super::{render_human_to, render_ndjson_to, render_table, Table}; use crate::commands::runtime; use crate::domain::runtime::{ AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView, @@ -2934,11 +2951,10 @@ mod tests { "/workspace/.radroots/config.toml" ); assert_eq!(view.account.selector.as_deref(), Some("acct_demo")); - assert!( - view.account - .store_path - .ends_with(".radroots/data/shared/accounts/store.json") - ); + assert!(view + .account + .store_path + .ends_with(".radroots/data/shared/accounts/store.json")); assert_eq!(view.relay.count, 2); assert_eq!(view.relay.publish_policy, "any"); assert!(!view.hyf.enabled); @@ -2948,11 +2964,10 @@ mod tests { view.account.secret_backend.contract_default_backend, "host_vault" ); - assert!( - view.local - .replica_db_path - .ends_with(".radroots/data/apps/cli/replica/replica.sqlite") - ); + assert!(view + .local + .replica_db_path + .ends_with(".radroots/data/apps/cli/replica/replica.sqlite")); } #[test] @@ -3079,11 +3094,9 @@ mod tests { )); let mut buffer = Vec::new(); let error = render_ndjson_to(&mut buffer, &output).expect_err("unsupported ndjson"); - assert!( - error - .to_string() - .contains("`config show` does not support --ndjson") - ); + assert!(error + .to_string() + .contains("`config show` does not support --ndjson")); } #[test] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -7,7 +7,6 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; -use radroots_events::RadrootsNostrEvent; use radroots_events::farm::RadrootsFarmRef; use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; use radroots_events::listing::{ @@ -16,6 +15,7 @@ use radroots_events::listing::{ RadrootsListingStatus, }; use radroots_events::trade::RadrootsTradeListingValidationError; +use radroots_events::RadrootsNostrEvent; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_replica_db::ReplicaSql; @@ -30,13 +30,14 @@ use crate::domain::runtime::{ ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, ListingValidateView, ListingValidationIssueView, SyncFreshnessView, }; -use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon; use crate::runtime::daemon::DaemonRpcError; -use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; +use crate::runtime::farm_config; +use crate::runtime::signer::{resolve_actor_write_authority, ActorWriteBindingError}; use crate::runtime::sync::freshness_from_executor; +use crate::runtime::RuntimeError; const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft · local first"; @@ -132,6 +133,17 @@ struct ListingValidationContext { selected_account_id: Option<String>, selected_account_pubkey: Option<String>, selected_farm_d_tag: Option<String>, + farm_setup_action: String, +} + +#[derive(Debug, Clone)] +struct ListingAuthoringDefaults { + farm_config_present: bool, + selected_account_id: Option<String>, + selected_account_pubkey: Option<String>, + selected_farm_d_tag: Option<String>, + delivery_method: Option<String>, + location: Option<ListingDraftLocation>, } #[derive(Debug, Clone)] @@ -163,41 +175,50 @@ pub fn scaffold( config: &RuntimeConfig, args: &ListingNewArgs, ) -> Result<ListingNewView, RuntimeError> { - let selected_account = accounts::resolve_account(config)?; - let seller_pubkey = selected_account - .as_ref() - .map(|account| account.record.public_identity.public_key_hex.clone()); - let farm_d_tag = match seller_pubkey.as_deref() { - Some(pubkey) => resolve_selected_farm_d_tag(config, pubkey)?, - None => None, - }; + let defaults = authoring_defaults(config)?; + let quantity_unit = args.quantity_unit.clone().unwrap_or_else(|| "g".to_owned()); let draft = ListingDraftDocument { version: 1, kind: DRAFT_KIND.to_owned(), listing: ListingDraftMeta { d_tag: generate_d_tag(), - farm_d_tag: farm_d_tag.clone().unwrap_or_default(), - seller_pubkey: seller_pubkey.clone().unwrap_or_default(), + farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(), + seller_pubkey: defaults.selected_account_pubkey.clone().unwrap_or_default(), }, product: ListingDraftProduct { - key: String::new(), - title: String::new(), - category: String::new(), - summary: String::new(), + key: args.key.clone().unwrap_or_default(), + title: args.title.clone().unwrap_or_default(), + category: args.category.clone().unwrap_or_default(), + summary: args.summary.clone().unwrap_or_default(), }, primary_bin: ListingDraftPrimaryBin { - bin_id: "bin-1".to_owned(), - quantity_amount: "1000".to_owned(), - quantity_unit: "g".to_owned(), - price_amount: "0.01".to_owned(), - price_currency: "USD".to_owned(), - price_per_amount: "1".to_owned(), - price_per_unit: "g".to_owned(), - label: String::new(), + bin_id: args.bin_id.clone().unwrap_or_else(|| "bin-1".to_owned()), + quantity_amount: args + .quantity_amount + .clone() + .unwrap_or_else(|| "1000".to_owned()), + quantity_unit: quantity_unit.clone(), + price_amount: args + .price_amount + .clone() + .unwrap_or_else(|| "0.01".to_owned()), + price_currency: args + .price_currency + .clone() + .unwrap_or_else(|| "USD".to_owned()), + price_per_amount: args + .price_per_amount + .clone() + .unwrap_or_else(|| "1".to_owned()), + price_per_unit: args + .price_per_unit + .clone() + .unwrap_or_else(|| quantity_unit.clone()), + label: args.label.clone().unwrap_or_default(), }, inventory: ListingDraftInventory { - available: "1".to_owned(), + available: args.available.clone().unwrap_or_else(|| "1".to_owned()), }, availability: ListingDraftAvailability { kind: "status".to_owned(), @@ -206,14 +227,14 @@ pub fn scaffold( end: None, }, delivery: ListingDraftDelivery { - method: "pickup".to_owned(), + method: defaults.delivery_method.clone().unwrap_or_default(), }, - location: ListingDraftLocation { + location: defaults.location.clone().unwrap_or(ListingDraftLocation { primary: String::new(), city: None, region: None, country: None, - }, + }), }; let output_path = match &args.output { @@ -235,11 +256,11 @@ pub fn scaffold( "radroots listing validate {}", output_path.display() )]; - if seller_pubkey.is_none() { + if defaults.selected_account_pubkey.is_none() { actions.push("radroots account new".to_owned()); } - if farm_d_tag.is_none() { - actions.push("radroots sync status".to_owned()); + if !defaults.farm_config_present { + actions.push(farm_setup_action(config)?); } Ok(ListingNewView { @@ -247,9 +268,15 @@ pub fn scaffold( source: LISTING_SOURCE.to_owned(), file: output_path.display().to_string(), listing_id: draft.listing.d_tag, - selected_account_id: selected_account.map(|account| account.record.account_id.to_string()), - seller_pubkey, - farm_d_tag, + selected_account_id: defaults.selected_account_id, + seller_pubkey: defaults.selected_account_pubkey, + farm_d_tag: defaults.selected_farm_d_tag, + delivery_method: non_empty(draft.delivery.method.clone()), + location_primary: non_empty(draft.location.primary.clone()), + reason: (!defaults.farm_config_present).then(|| { + "selected farm config not found; delivery, location, and farm defaults were left blank" + .to_owned() + }), actions, }) } @@ -647,23 +674,27 @@ fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeErro RuntimeError::Config(format!("failed to render listing draft: {error}")) })?; Ok(format!( - "# radroots listing draft v1\n# fill the empty fields, then run `radroots listing validate <file>`\n\n{toml}" + "# 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}" )) } fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> { - let selected_account = accounts::resolve_account(config)?; - let selected_account_pubkey = selected_account - .as_ref() - .map(|account| account.record.public_identity.public_key_hex.clone()); - let selected_farm_d_tag = match selected_account_pubkey.as_deref() { - Some(pubkey) => resolve_selected_farm_d_tag(config, pubkey)?, - None => None, + let defaults = authoring_defaults(config)?; + let selected_farm_d_tag = match ( + defaults.farm_config_present, + defaults.selected_farm_d_tag, + defaults.selected_account_pubkey.clone(), + ) { + (true, d_tag, _) => d_tag, + (false, Some(d_tag), _) => Some(d_tag), + (false, None, Some(pubkey)) => resolve_selected_farm_d_tag(config, pubkey.as_str())?, + (false, None, None) => None, }; Ok(ListingValidationContext { - selected_account_id: selected_account.map(|account| account.record.account_id.to_string()), - selected_account_pubkey, + selected_account_id: defaults.selected_account_id, + selected_account_pubkey: defaults.selected_account_pubkey, selected_farm_d_tag, + farm_setup_action: farm_setup_action(config)?, }) } @@ -716,7 +747,7 @@ fn canonicalize_draft( return Err(issue_for_field( contents, "listing.farm_d_tag", - "missing farm_d_tag and no matching local farm was found for the selected account", + "missing farm_d_tag and no selected farm config is available", )); }; if !is_d_tag_base64url(&farm_d_tag) { @@ -928,7 +959,7 @@ fn invalid_validation_view( actions.push("radroots account new".to_owned()); } if context.selected_farm_d_tag.is_none() { - actions.push("radroots sync status".to_owned()); + actions.push(context.farm_setup_action.clone()); } ListingValidateView { @@ -1196,6 +1227,42 @@ fn issue_from_trade_validation( } } +fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> { + let selected_account = accounts::resolve_account(config)?; + let mut defaults = ListingAuthoringDefaults { + farm_config_present: false, + selected_account_id: selected_account + .as_ref() + .map(|account| account.record.account_id.to_string()), + selected_account_pubkey: selected_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.clone()), + selected_farm_d_tag: None, + delivery_method: None, + location: None, + }; + + let Some(resolved) = farm_config::load(config, None)? else { + return Ok(defaults); + }; + let Some(account) = configured_account(config, &resolved.document.selection.account)? else { + return Err(RuntimeError::Config(format!( + "farm config account `{}` is not present in the local account store", + resolved.document.selection.account + ))); + }; + + defaults.farm_config_present = true; + defaults.selected_account_id = Some(resolved.document.selection.account.clone()); + defaults.selected_account_pubkey = Some(account.record.public_identity.public_key_hex.clone()); + defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); + defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone()); + defaults.location = Some(draft_location_from_model( + &resolved.document.listing_defaults.location, + )); + Ok(defaults) +} + fn resolve_selected_farm_d_tag( config: &RuntimeConfig, seller_pubkey: &str, @@ -1208,6 +1275,34 @@ fn resolve_selected_farm_d_tag( .map_err(RuntimeError::from) } +fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraftLocation { + ListingDraftLocation { + primary: location.primary.clone(), + city: location.city.clone(), + region: location.region.clone(), + country: location.country.clone(), + } +} + +fn farm_setup_action(config: &RuntimeConfig) -> Result<String, RuntimeError> { + let scope = farm_config::resolve_scope(&config.paths, None)?; + Ok(format!( + "radroots farm setup --scope {} --name <farm-name> --location <place>", + scope.as_str() + )) +} + +fn configured_account( + config: &RuntimeConfig, + account_id: &str, +) -> Result<Option<accounts::AccountRecordView>, RuntimeError> { + let snapshot = accounts::snapshot(config)?; + Ok(snapshot + .accounts + .into_iter() + .find(|account| account.record.account_id.as_str() == account_id)) +} + fn parse_decimal_field( value: &str, contents: &str, @@ -1356,7 +1451,7 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { #[cfg(test)] mod tests { - use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag}; + use super::{encode_base64url_no_pad, generate_d_tag, ListingDraftDocument, DRAFT_KIND}; use radroots_events_codec::d_tag::is_d_tag_base64url; #[test] diff --git a/tests/listing.rs b/tests/listing.rs @@ -11,7 +11,7 @@ use std::time::Duration; use assert_cmd::prelude::*; use radroots_sql_core::{SqlExecutor, SqliteExecutor}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use tempfile::tempdir; fn data_root(workdir: &Path) -> std::path::PathBuf { @@ -134,14 +134,9 @@ fn listing_test_guard() -> MutexGuard<'static, ()> { } #[test] -fn listing_new_scaffolds_a_toml_draft_with_account_and_farm_defaults() { +fn listing_new_uses_selected_farm_config_and_product_inputs() { let _guard = listing_test_guard(); let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); let account_output = cli_command_in(dir.path()) .args(["--json", "account", "new"]) @@ -153,12 +148,63 @@ fn listing_new_scaffolds_a_toml_draft_with_account_and_farm_defaults() { let seller_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() .expect("seller pubkey"); - let account_id = account_json["account"]["id"].as_str().expect("account id"); - let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; - seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta"); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); + + let setup_output = cli_command_in(dir.path()) + .args([ + "--json", + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--city", + "San Francisco", + "--region", + "CA", + "--country", + "US", + "--delivery-method", + "pickup", + ]) + .output() + .expect("run farm setup"); + assert!(setup_output.status.success()); + let setup_json: Value = + serde_json::from_slice(setup_output.stdout.as_slice()).expect("setup json"); + let farm_d_tag = setup_json["config"]["farm_d_tag"] + .as_str() + .expect("farm d_tag") + .to_owned(); let output = cli_command_in(dir.path()) - .args(["--json", "listing", "new"]) + .args([ + "--json", + "listing", + "new", + "--key", + "sf-tomatoes", + "--title", + "San Francisco Early Girl Tomatoes", + "--category", + "produce.vegetables.tomatoes", + "--summary", + "Fresh local tomatoes packed for pickup from the seller's standard market location.", + "--quantity-amount", + "1000", + "--quantity-unit", + "g", + "--price-amount", + "0.01", + "--available", + "25", + "--label", + "1 kg tomato lot", + ]) .output() .expect("run listing new"); assert!(output.status.success()); @@ -167,22 +213,27 @@ fn listing_new_scaffolds_a_toml_draft_with_account_and_farm_defaults() { assert_eq!(json["selected_account_id"], account_id); assert_eq!(json["seller_pubkey"], seller_pubkey); assert_eq!(json["farm_d_tag"], farm_d_tag); + assert_eq!(json["delivery_method"], "pickup"); + assert_eq!(json["location_primary"], "San Francisco, CA"); let file = json["file"].as_str().expect("draft file"); let contents = fs::read_to_string(file).expect("draft contents"); assert!(contents.contains("kind = \"listing_draft_v1\"")); assert!(contents.contains(&format!("seller_pubkey = \"{seller_pubkey}\""))); assert!(contents.contains(&format!("farm_d_tag = \"{farm_d_tag}\""))); + assert!(contents.contains("key = \"sf-tomatoes\"")); + assert!(contents.contains("title = \"San Francisco Early Girl Tomatoes\"")); + assert!(contents.contains("category = \"produce.vegetables.tomatoes\"")); + assert!(contents.contains("method = \"pickup\"")); + assert!(contents.contains("primary = \"San Francisco, CA\"")); + assert!(contents.contains("price_currency = \"USD\"")); + assert!(contents.contains("price_per_amount = \"1\"")); + assert!(contents.contains("price_per_unit = \"g\"")); } #[test] -fn listing_validate_resolves_selected_account_and_matching_farm() { +fn listing_validate_resolves_selected_farm_config_defaults() { let _guard = listing_test_guard(); let dir = tempdir().expect("tempdir"); - let init = cli_command_in(dir.path()) - .args(["local", "init"]) - .output() - .expect("run local init"); - assert!(init.status.success()); let account_output = cli_command_in(dir.path()) .args(["--json", "account", "new"]) @@ -195,8 +246,27 @@ fn listing_validate_resolves_selected_account_and_matching_farm() { .as_str() .expect("seller pubkey") .to_owned(); - let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; - seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta"); + let setup_output = cli_command_in(dir.path()) + .args([ + "--json", + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--delivery-method", + "pickup", + ]) + .output() + .expect("run farm setup"); + assert!(setup_output.status.success()); + let setup_json: Value = + serde_json::from_slice(setup_output.stdout.as_slice()).expect("setup json"); + let farm_d_tag = setup_json["config"]["farm_d_tag"] + .as_str() + .expect("farm d_tag") + .to_owned(); let draft_path = dir.path().join("eggs.toml"); fs::write( @@ -1157,12 +1227,10 @@ fn listing_publish_without_matching_signer_session_exits_unconfigured() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"] - .as_str() - .expect("reason") - .contains("no authorized signer session matched seller pubkey") - ); + assert!(publish_json["reason"] + .as_str() + .expect("reason") + .contains("no authorized signer session matched seller pubkey")); let recorded = requests.lock().expect("requests"); assert_eq!(recorded.len(), 1); @@ -1256,12 +1324,10 @@ fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"] - .as_str() - .expect("reason") - .contains("does not match seller pubkey") - ); + assert!(publish_json["reason"] + .as_str() + .expect("reason") + .contains("does not match seller pubkey")); let recorded = requests.lock().expect("requests"); assert_eq!(recorded.len(), 1); @@ -1342,11 +1408,10 @@ fn listing_publish_requires_authoritative_write_plane_binding() { let publish_json: Value = serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); assert_eq!(publish_json["state"], "unconfigured"); - assert!( - publish_json["reason"].as_str().expect("reason").contains( - "explicit write-plane capability binding or managed radrootsd instance `local`" - ) - ); + assert!(publish_json["reason"] + .as_str() + .expect("reason") + .contains("explicit write-plane capability binding or managed radrootsd instance `local`")); assert!(requests.lock().expect("requests").is_empty()); }