commit 42036881bca6cca83280477ad78ca1aeeed5f516
parent b97c057872fdbdf6be3599d0396f684ea1e35a49
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 21:16:24 +0000
implement human first market wrappers
Diffstat:
5 files changed, 846 insertions(+), 3 deletions(-)
diff --git a/src/commands/market.rs b/src/commands/market.rs
@@ -0,0 +1,145 @@
+use crate::cli::{FindArgs, RecordKeyArgs};
+use crate::domain::runtime::{
+ CommandDisposition, CommandOutput, CommandView, FindView, ListingGetView, SyncActionView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn update(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
+ let view = market_update_view(crate::runtime::sync::pull(config)?);
+ Ok(market_update_output(view))
+}
+
+pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = market_search_view(crate::runtime::find::search(config, args)?);
+ Ok(market_search_output(view))
+}
+
+pub fn view(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = market_view_view(crate::runtime::listing::get(config, args)?);
+ Ok(market_view_output(view))
+}
+
+fn market_update_view(mut view: SyncActionView) -> SyncActionView {
+ view.actions = match view.state.as_str() {
+ "ready" => vec!["radroots market search tomatoes".to_owned()],
+ "unavailable" => vec![
+ "radroots rpc status".to_owned(),
+ "radroots runtime status radrootsd".to_owned(),
+ "radroots sync status".to_owned(),
+ ],
+ "unconfigured" => {
+ let mut actions = Vec::new();
+ if view.replica_db == "missing" {
+ actions.push("radroots local init".to_owned());
+ }
+ if view.relay_count == 0 {
+ actions.push("radroots relay list --relay wss://relay.example.com".to_owned());
+ }
+ if actions.is_empty() {
+ actions.extend(view.actions.clone());
+ }
+ actions
+ }
+ _ => view.actions.clone(),
+ };
+ view
+}
+
+fn market_search_view(mut view: FindView) -> FindView {
+ view.actions = match view.state.as_str() {
+ "ready" => view
+ .results
+ .first()
+ .map(|result| {
+ vec![
+ format!("radroots market view {}", result.product_key),
+ format!("radroots order create --listing {}", result.product_key),
+ ]
+ })
+ .unwrap_or_default(),
+ "empty" => vec![
+ "radroots market update".to_owned(),
+ "radroots market search eggs".to_owned(),
+ ],
+ _ => view.actions.clone(),
+ };
+ view
+}
+
+fn market_view_view(mut view: ListingGetView) -> ListingGetView {
+ view.actions = match view.state.as_str() {
+ "ready" => {
+ let listing_key = view
+ .product_key
+ .as_deref()
+ .unwrap_or(view.lookup.as_str())
+ .to_owned();
+ vec![format!("radroots order create --listing {listing_key}")]
+ }
+ "missing" => vec![
+ "radroots market search tomatoes".to_owned(),
+ "radroots market update".to_owned(),
+ ],
+ "unconfigured" => vec![
+ "radroots local init".to_owned(),
+ "radroots market update".to_owned(),
+ ],
+ _ => view.actions.clone(),
+ };
+ view
+}
+
+fn market_update_output(view: SyncActionView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::MarketUpdate(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::MarketUpdate(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::MarketUpdate(view))
+ }
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::MarketUpdate(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::MarketUpdate(view))
+ }
+ }
+}
+
+fn market_search_output(view: FindView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::MarketSearch(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::MarketSearch(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::MarketSearch(view))
+ }
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::MarketSearch(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::MarketSearch(view))
+ }
+ }
+}
+
+fn market_view_output(view: ListingGetView) -> CommandOutput {
+ match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::MarketView(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::MarketView(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::MarketView(view))
+ }
+ CommandDisposition::Unsupported => {
+ CommandOutput::unsupported(CommandView::MarketView(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::MarketView(view))
+ }
+ }
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -5,6 +5,7 @@ pub mod identity;
pub mod job;
pub mod listing;
pub mod local;
+pub mod market;
pub mod myc;
pub mod net;
pub mod order;
@@ -82,9 +83,9 @@ pub fn dispatch(
LocalCommand::Backup(args) => local::backup(config, args),
},
Command::Market(market) => match &market.command {
- MarketCommand::Update => sync::pull(config),
- MarketCommand::Search(args) => find::search(config, args),
- MarketCommand::View(args) => listing::get(config, args),
+ MarketCommand::Update => market::update(config),
+ MarketCommand::Search(args) => market::search(config, args),
+ MarketCommand::View(args) => market::view(config, args),
},
Command::Net(net) => match &net.command {
NetCommand::Status => net::status(config),
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -103,6 +103,9 @@ pub enum CommandView {
LocalExport(LocalExportView),
LocalInit(LocalInitView),
LocalStatus(LocalStatusView),
+ MarketSearch(FindView),
+ MarketUpdate(SyncActionView),
+ MarketView(ListingGetView),
MycStatus(MycStatusView),
NetStatus(NetStatusView),
OrderCancel(OrderCancelView),
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -166,6 +166,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::LocalStatus(view) => {
render_local_status(stdout, view)?;
}
+ CommandView::MarketSearch(view) => {
+ render_market_search(stdout, view)?;
+ }
+ CommandView::MarketUpdate(view) => {
+ render_market_update(stdout, view)?;
+ }
+ CommandView::MarketView(view) => {
+ render_market_view(stdout, view)?;
+ }
CommandView::RelayList(view) => {
render_relay_list(stdout, view)?;
}
@@ -380,6 +389,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::MarketSearch(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::MarketUpdate(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::MarketView(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::RelayList(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -460,6 +481,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<()
}
Ok(())
}
+ CommandView::MarketSearch(view) => {
+ for result in &view.results {
+ serde_json::to_writer(&mut *stdout, result)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
CommandView::JobList(view) => {
for job in &view.jobs {
serde_json::to_writer(&mut *stdout, job)?;
@@ -1151,6 +1179,104 @@ fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeErr
Ok(())
}
+fn render_market_search(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> {
+ match view.state.as_str() {
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ writeln!(stdout)?;
+ render_item_section(stdout, "Missing", &["Local market data".to_owned()])?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "empty" => {
+ writeln!(stdout, "No listings found")?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "{}", market_search_headline(view))?;
+ writeln!(stdout)?;
+ for (index, result) in view.results.iter().enumerate() {
+ render_market_search_card(stdout, result)?;
+ if index + 1 < view.results.len() {
+ writeln!(stdout)?;
+ }
+ }
+ if let Some(hyf) = &view.hyf {
+ if hyf.rewritten_query.trim() != view.query.trim() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Also searched for", &[view.query.clone()])?;
+ }
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn render_market_search_card(
+ stdout: &mut dyn Write,
+ result: &crate::domain::runtime::FindResultView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "{}", result.title)?;
+ let mut rows = vec![("Key", result.product_key.clone())];
+ push_row(
+ &mut rows,
+ "Place",
+ result
+ .location_primary
+ .as_deref()
+ .and_then(non_empty_str)
+ .map(str::to_owned),
+ );
+ push_row(&mut rows, "Offer", quantity_offer_text(&result.available));
+ rows.push((
+ "Price",
+ format_price(
+ result.price.amount,
+ &result.price.currency,
+ result.price.per_amount,
+ &result.price.per_unit,
+ ),
+ ));
+ rows.push((
+ "Stock",
+ format_available(
+ result
+ .available
+ .available_amount
+ .unwrap_or(result.available.total_amount),
+ result
+ .available
+ .label
+ .as_deref()
+ .unwrap_or(result.available.total_unit.as_str()),
+ ),
+ ));
+ render_field_rows(stdout, rows.as_slice())
+}
+
+fn market_search_headline(view: &FindView) -> String {
+ let query = view
+ .hyf
+ .as_ref()
+ .map(|hyf| hyf.rewritten_query.as_str())
+ .unwrap_or(view.query.as_str());
+ format!(
+ "{} listing{} for {}",
+ view.count,
+ if view.count == 1 { "" } else { "s" },
+ query
+ )
+}
+
fn render_job_list(stdout: &mut dyn Write, view: &JobListView) -> Result<(), RuntimeError> {
let context = match view.state.as_str() {
"ready" => format!(
@@ -1904,6 +2030,87 @@ fn render_listing_get(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(
Ok(())
}
+fn render_market_view(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(), RuntimeError> {
+ match view.state.as_str() {
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "missing" => {
+ writeln!(stdout, "Listing not found")?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ let headline = view.title.as_deref().unwrap_or("Listing");
+ writeln!(stdout, "{headline}")?;
+ writeln!(stdout)?;
+ let mut rows = Vec::<(&str, String)>::new();
+ push_row(
+ &mut rows,
+ "Key",
+ view.product_key
+ .as_deref()
+ .and_then(non_empty_str)
+ .map(str::to_owned),
+ );
+ push_row(
+ &mut rows,
+ "Category",
+ view.category
+ .as_deref()
+ .and_then(non_empty_str)
+ .map(str::to_owned),
+ );
+ push_row(
+ &mut rows,
+ "Place",
+ view.location_primary
+ .as_deref()
+ .and_then(non_empty_str)
+ .map(str::to_owned),
+ );
+ if let Some(available) = &view.available {
+ push_row(&mut rows, "Offer", quantity_offer_text(available));
+ rows.push((
+ "Stock",
+ format_available(
+ available.available_amount.unwrap_or(available.total_amount),
+ available
+ .label
+ .as_deref()
+ .unwrap_or(available.total_unit.as_str()),
+ ),
+ ));
+ }
+ if let Some(price) = &view.price {
+ rows.push((
+ "Price",
+ format_price(
+ price.amount,
+ &price.currency,
+ price.per_amount,
+ &price.per_unit,
+ ),
+ ));
+ }
+ render_owned_pairs(stdout, "Listing", rows.as_slice())?;
+ if let Some(description) = &view.description {
+ render_owned_pairs(stdout, "About", &[("Summary", description.clone())])?;
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ }
+ Ok(())
+}
+
fn render_listing_mutation(
stdout: &mut dyn Write,
view: &ListingMutationView,
@@ -2189,6 +2396,57 @@ fn render_sync_action(stdout: &mut dyn Write, view: &SyncActionView) -> Result<(
Ok(())
}
+fn render_market_update(stdout: &mut dyn Write, view: &SyncActionView) -> Result<(), RuntimeError> {
+ match view.state.as_str() {
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ let mut missing = Vec::new();
+ if view.replica_db == "missing" {
+ missing.push("Local market data".to_owned());
+ }
+ if view.relay_count == 0 {
+ missing.push("Relay configuration".to_owned());
+ }
+ if !missing.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Missing", &missing)?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unavailable" => {
+ writeln!(stdout, "Unavailable right now")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "Market data updated")?;
+ writeln!(stdout)?;
+ render_owned_pairs(
+ stdout,
+ "Local data",
+ &[
+ ("State", view.state.clone()),
+ ("Updated", view.freshness.display.clone()),
+ ("Relays", view.relay_count.to_string()),
+ ],
+ )?;
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ }
+ Ok(())
+}
+
fn render_sync_watch(stdout: &mut dyn Write, view: &SyncWatchView) -> Result<(), RuntimeError> {
write_context(stdout, "activity · sync watch")?;
if view.frames.is_empty() {
@@ -2783,6 +3041,19 @@ fn render_pairs(
Ok(())
}
+fn render_field_rows(stdout: &mut dyn Write, rows: &[(&str, String)]) -> Result<(), RuntimeError> {
+ let label_width = rows
+ .iter()
+ .map(|(label, _)| label.len())
+ .max()
+ .unwrap_or_default();
+ for (label, value) in rows {
+ writeln!(stdout, " {label:label_width$} {value}")?;
+ }
+ writeln!(stdout)?;
+ Ok(())
+}
+
fn render_owned_pairs(
stdout: &mut dyn Write,
heading: &str,
@@ -3013,6 +3284,15 @@ fn format_price(amount: f64, currency: &str, per_amount: u32, per_unit: &str) ->
)
}
+fn quantity_offer_text(quantity: &crate::domain::runtime::FindQuantityView) -> Option<String> {
+ quantity
+ .label
+ .as_deref()
+ .and_then(non_empty_str)
+ .map(str::to_owned)
+ .or_else(|| Some(format!("{} {}", quantity.total_amount, quantity.total_unit)))
+}
+
fn format_available(amount: i64, unit: &str) -> String {
format!("{amount} {unit}")
}
@@ -3066,6 +3346,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::LocalExport(_) => "local export",
CommandView::LocalInit(_) => "local init",
CommandView::LocalStatus(_) => "local status",
+ CommandView::MarketSearch(_) => "market search",
+ CommandView::MarketUpdate(_) => "market update",
+ CommandView::MarketView(_) => "market view",
CommandView::MycStatus(_) => "myc status",
CommandView::NetStatus(_) => "net status",
CommandView::OrderCancel(_) => "order cancel",
diff --git a/tests/market.rs b/tests/market.rs
@@ -0,0 +1,411 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+
+use assert_cmd::prelude::*;
+use radroots_sql_core::{SqlExecutor, SqliteExecutor};
+use serde_json::{Value, json};
+use tempfile::tempdir;
+
+fn data_root(workdir: &Path) -> std::path::PathBuf {
+ if cfg!(windows) {
+ workdir.join("local").join("Radroots").join("data")
+ } else {
+ workdir.join("home").join(".radroots").join("data")
+ }
+}
+
+fn cli_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ command.env("APPDATA", workdir.join("roaming"));
+ command.env("LOCALAPPDATA", workdir.join("local"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_CLI_PATHS_PROFILE",
+ "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_ACCOUNT_SECRET_BACKEND",
+ "RADROOTS_ACCOUNT_SECRET_FALLBACK",
+ "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE",
+ "RADROOTS_HYF_ENABLED",
+ "RADROOTS_HYF_EXECUTABLE",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
+ ] {
+ command.env_remove(key);
+ }
+ command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false");
+ command
+}
+
+fn seed_trade_product(
+ workdir: &Path,
+ product_id: &str,
+ key: &str,
+ category: &str,
+ title: &str,
+ summary: &str,
+ qty_amt: i64,
+ qty_avail: i64,
+ location_label: Option<&str>,
+) {
+ let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite");
+ let executor = SqliteExecutor::open(&replica_db).expect("open replica db");
+ let now = "2026-04-07T00:00:00.000Z";
+ executor
+ .exec(
+ "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ product_id,
+ now,
+ now,
+ key,
+ category,
+ title,
+ summary,
+ "fresh",
+ "lot-a",
+ "standard",
+ 2026,
+ qty_amt,
+ "kg",
+ "1 kg tomato lot",
+ qty_avail,
+ 10.0,
+ "USD",
+ 1,
+ "kg",
+ Value::Null
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert trade product");
+
+ if let Some(location_label) = location_label {
+ let location_id = format!("11111111-1111-1111-1111-{}", &product_id[24..]);
+ executor
+ .exec(
+ "INSERT INTO gcs_location (id, created_at, updated_at, d_tag, lat, lng, geohash, point, polygon, accuracy, altitude, tag_0, label, area, elevation, soil, climate, gc_id, gc_name, gc_admin1_id, gc_admin1_name, gc_country_id, gc_country_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
+ json!([
+ location_id,
+ now,
+ now,
+ format!("location-{product_id}"),
+ 35.0,
+ -82.0,
+ "dnrj",
+ "POINT(-82 35)",
+ "POLYGON EMPTY",
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ location_label,
+ Value::Null,
+ Value::Null,
+ Value::Null,
+ "USA"
+ ])
+ .to_string()
+ .as_str(),
+ )
+ .expect("insert gcs location");
+ executor
+ .exec(
+ "INSERT INTO trade_product_location (tb_tp, tb_gl) VALUES (?, ?);",
+ json!([product_id, location_id]).to_string().as_str(),
+ )
+ .expect("insert trade product location");
+ }
+}
+
+fn write_fake_hyfd(
+ workdir: &Path,
+ status_response: &str,
+ rewrite_response: &str,
+) -> std::path::PathBuf {
+ let path = workdir.join("fake-hyfd");
+ let script = format!(
+ "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n cat <<'JSON'\n{status_response}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n cat <<'JSON'\n{rewrite_response}\nJSON\n ;;\n *)\n cat <<'JSON'\n{{\"version\":1,\"request_id\":\"unexpected\",\"ok\":false,\"error\":{{\"code\":\"unsupported_capability\",\"message\":\"unexpected request\"}}}}\nJSON\n ;;\nesac\n"
+ );
+ fs::write(&path, script).expect("write fake hyfd");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut permissions = fs::metadata(&path).expect("metadata").permissions();
+ permissions.set_mode(0o755);
+ fs::set_permissions(&path, permissions).expect("chmod fake hyfd");
+ }
+ path
+}
+
+#[test]
+fn market_update_reports_missing_local_data_and_relay_setup() {
+ let dir = tempdir().expect("tempdir");
+ let output = cli_command_in(dir.path())
+ .args(["market", "update"])
+ .output()
+ .expect("run market update");
+
+ assert_eq!(output.status.code(), Some(3));
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Not ready yet"));
+ assert!(stdout.contains("Missing"));
+ assert!(stdout.contains("Local market data"));
+ assert!(stdout.contains("Relay configuration"));
+ assert!(stdout.contains("radroots local init"));
+ assert!(stdout.contains("radroots relay list --relay wss://relay.example.com"));
+}
+
+#[test]
+fn market_update_stays_honest_about_unavailable_ingest() {
+ 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 config_dir = dir.path().join(".radroots");
+ fs::create_dir_all(&config_dir).expect("workspace config dir");
+ fs::write(
+ config_dir.join("config.toml"),
+ "[relay]\nurls = [\"wss://relay.one\"]\npublish_policy = \"any\"\n",
+ )
+ .expect("write workspace config");
+
+ let json_output = cli_command_in(dir.path())
+ .args(["--json", "market", "update"])
+ .output()
+ .expect("run market update json");
+ assert_eq!(json_output.status.code(), Some(4));
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["direction"], "pull");
+ assert_eq!(json["state"], "unavailable");
+ assert_eq!(json["relay_count"], 1);
+ assert_eq!(json["actions"][0], "radroots rpc status");
+ assert_eq!(json["actions"][1], "radroots runtime status radrootsd");
+ assert_eq!(json["actions"][2], "radroots sync status");
+ assert!(
+ json["reason"]
+ .as_str()
+ .is_some_and(|reason| reason.contains("relay ingest"))
+ );
+
+ let human_output = cli_command_in(dir.path())
+ .args(["market", "update"])
+ .output()
+ .expect("run market update human");
+ assert_eq!(human_output.status.code(), Some(4));
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Unavailable right now"));
+ assert!(stdout.contains("relay ingest is not wired into `radroots sync pull` yet"));
+ assert!(stdout.contains("Next"));
+ assert!(stdout.contains("radroots rpc status"));
+}
+
+#[test]
+fn market_search_preserves_machine_shape_and_renders_card_list() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000401",
+ "sf-tomatoes",
+ "produce",
+ "San Francisco Early Girl Tomatoes",
+ "Fresh local tomatoes packed for pickup from the farm.",
+ 18,
+ 12,
+ Some("San Francisco, CA"),
+ );
+
+ let json_output = cli_command_in(dir.path())
+ .args(["--json", "market", "search", "tomatoes"])
+ .output()
+ .expect("run market search json");
+ assert!(json_output.status.success());
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["count"], 1);
+ assert_eq!(json["results"][0]["product_key"], "sf-tomatoes");
+ assert_eq!(
+ json["results"][0]["title"],
+ "San Francisco Early Girl Tomatoes"
+ );
+ assert_eq!(json["results"][0]["location_primary"], "San Francisco, CA");
+ assert_eq!(json["actions"][0], "radroots market view sf-tomatoes");
+ assert_eq!(
+ json["actions"][1],
+ "radroots order create --listing sf-tomatoes"
+ );
+
+ let ndjson_output = cli_command_in(dir.path())
+ .args(["--ndjson", "market", "search", "tomatoes"])
+ .output()
+ .expect("run market search ndjson");
+ assert!(ndjson_output.status.success());
+ let stdout = String::from_utf8(ndjson_output.stdout).expect("utf8 stdout");
+ let lines = stdout.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 1);
+ assert!(lines[0].contains("\"product_key\":\"sf-tomatoes\""));
+ assert!(lines[0].contains("\"title\":\"San Francisco Early Girl Tomatoes\""));
+
+ let human_output = cli_command_in(dir.path())
+ .args(["market", "search", "tomatoes"])
+ .output()
+ .expect("run market search human");
+ assert!(human_output.status.success());
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("1 listing for tomatoes"));
+ assert!(stdout.contains("San Francisco Early Girl Tomatoes"));
+ assert!(stdout.contains("Key"));
+ assert!(stdout.contains("Place"));
+ assert!(stdout.contains("Offer"));
+ assert!(stdout.contains("Next"));
+ assert!(stdout.contains("radroots market view sf-tomatoes"));
+ assert!(stdout.contains("radroots order create --listing sf-tomatoes"));
+ assert!(!stdout.contains("market · local first"));
+}
+
+#[test]
+fn market_search_uses_also_searched_for_when_hyf_rewrites_query() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000402",
+ "fresh-eggs",
+ "protein",
+ "Fresh Eggs",
+ "Pasture-raised eggs",
+ 36,
+ 24,
+ Some("Marshall"),
+ );
+
+ let hyfd = write_fake_hyfd(
+ dir.path(),
+ r#"{"version":1,"request_id":"cli-doctor-hyf-status","trace_id":"cli-doctor-hyf-status","ok":true,"output":{"build_identity":{"protocol_version":1},"enabled_execution_modes":{"deterministic":true}}}"#,
+ r#"{"version":1,"request_id":"cli-find-query-rewrite","trace_id":"cli-find-query-rewrite","ok":true,"output":{"original_text":"henhouse","normalized_text":"henhouse","rewritten_text":"eggs","query_terms":["eggs"],"normalization_signals":["query_rewrite"],"ranking_hints":["local_first"],"extracted_filters":{"local_intent":false,"fulfillment":"any","time_window":"any"}}}"#,
+ );
+
+ let json_output = cli_command_in(dir.path())
+ .env("RADROOTS_HYF_ENABLED", "true")
+ .env("RADROOTS_HYF_EXECUTABLE", &hyfd)
+ .args(["--json", "market", "search", "henhouse"])
+ .output()
+ .expect("run market search json");
+ assert!(json_output.status.success());
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["hyf"]["state"], "query_rewrite_applied");
+ assert_eq!(json["hyf"]["rewritten_query"], "eggs");
+
+ let human_output = cli_command_in(dir.path())
+ .env("RADROOTS_HYF_ENABLED", "true")
+ .env("RADROOTS_HYF_EXECUTABLE", &hyfd)
+ .args(["market", "search", "henhouse"])
+ .output()
+ .expect("run market search human");
+ assert!(human_output.status.success());
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("1 listing for eggs"));
+ assert!(stdout.contains("Also searched for"));
+ assert!(stdout.contains("henhouse"));
+ assert!(!stdout.contains("hyf: query rewritten"));
+}
+
+#[test]
+fn market_view_wraps_listing_reads_and_guides_to_order_create() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000403",
+ "pasture-eggs",
+ "protein",
+ "Pasture Eggs",
+ "Fresh pasture-raised eggs collected daily.",
+ 36,
+ 18,
+ Some("Marshall"),
+ );
+
+ let json_output = cli_command_in(dir.path())
+ .args(["--json", "market", "view", "pasture-eggs"])
+ .output()
+ .expect("run market view json");
+ assert!(json_output.status.success());
+ let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "ready");
+ assert_eq!(json["product_key"], "pasture-eggs");
+ assert_eq!(json["title"], "Pasture Eggs");
+ assert_eq!(json["location_primary"], "Marshall");
+ assert_eq!(
+ json["actions"][0],
+ "radroots order create --listing pasture-eggs"
+ );
+
+ let human_output = cli_command_in(dir.path())
+ .args(["market", "view", "pasture-eggs"])
+ .output()
+ .expect("run market view human");
+ assert!(human_output.status.success());
+ let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
+ assert!(stdout.contains("Pasture Eggs"));
+ assert!(stdout.contains("Listing"));
+ assert!(stdout.contains("Key"));
+ assert!(stdout.contains("Place"));
+ assert!(stdout.contains("About"));
+ assert!(stdout.contains("radroots order create --listing pasture-eggs"));
+ assert!(!stdout.contains("listing ·"));
+
+ let missing_output = cli_command_in(dir.path())
+ .args(["--json", "market", "view", "missing-listing"])
+ .output()
+ .expect("run missing market view");
+ assert!(missing_output.status.success());
+ let missing_json: Value =
+ serde_json::from_slice(missing_output.stdout.as_slice()).expect("json");
+ assert_eq!(missing_json["state"], "missing");
+ assert_eq!(
+ missing_json["actions"][0],
+ "radroots market search tomatoes"
+ );
+ assert_eq!(missing_json["actions"][1], "radroots market update");
+}