cli

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

commit 42036881bca6cca83280477ad78ca1aeeed5f516
parent b97c057872fdbdf6be3599d0396f684ea1e35a49
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 21:16:24 +0000

implement human first market wrappers

Diffstat:
Asrc/commands/market.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/mod.rs | 7++++---
Msrc/domain/runtime.rs | 3+++
Msrc/render/mod.rs | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/market.rs | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"); +}