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:
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());
}