cli

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

commit fcaa7c3b018252041b8d3e63a16a6e2a0daebe0f
parent 530715c3e1a68a5da7a4fe83eb259e5cc310704c
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Apr 2026 05:25:13 +0000

land local-first find command

Diffstat:
Asrc/commands/find.rs | 16++++++++++++++++
Msrc/commands/mod.rs | 3++-
Msrc/domain/runtime.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/runtime/find.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/mod.rs | 1+
Msrc/runtime/sync.rs | 4+++-
Atests/find.rs | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 616 insertions(+), 4 deletions(-)

diff --git a/src/commands/find.rs b/src/commands/find.rs @@ -0,0 +1,16 @@ +use crate::cli::FindArgs; +use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::find::search(config, args)?; + Ok(match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::Find(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::Find(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::Find(view)) + } + CommandDisposition::InternalError => CommandOutput::internal_error(CommandView::Find(view)), + }) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod doctor; +pub mod find; pub mod identity; pub mod local; pub mod myc; @@ -45,7 +46,7 @@ pub fn dispatch( SignerCommand::Status => Ok(signer::status(config)), }, Command::Doctor => doctor::report(config, logging), - Command::Find(_) => unimplemented_command("find"), + Command::Find(find_args) => find::search(config, find_args), Command::Job(job) => match &job.command { JobCommand::Ls => unimplemented_command("job ls"), JobCommand::Get(_) => unimplemented_command("job get"), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -74,6 +74,7 @@ pub enum CommandView { AccountWhoami(AccountWhoamiView), ConfigShow(ConfigShowView), Doctor(DoctorView), + Find(FindView), LocalBackup(LocalBackupView), LocalExport(LocalExportView), LocalInit(LocalInitView), @@ -327,6 +328,71 @@ pub struct LocalReplicaSyncView { } #[derive(Debug, Clone, Serialize)] +pub struct FindView { + pub state: String, + pub source: String, + pub query: String, + pub count: usize, + pub relay_count: usize, + pub replica_db: String, + pub freshness: SyncFreshnessView, + pub results: Vec<FindResultView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FindView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindResultView { + pub id: String, + pub product_key: String, + pub title: String, + pub category: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_primary: Option<String>, + pub available: FindQuantityView, + pub price: FindPriceView, + pub provenance: FindResultProvenanceView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindQuantityView { + pub total_amount: i64, + pub total_unit: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub available_amount: Option<i64>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindPriceView { + pub amount: f64, + pub currency: String, + pub per_amount: u32, + pub per_unit: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FindResultProvenanceView { + pub origin: String, + pub freshness: String, + pub relay_count: usize, +} + +#[derive(Debug, Clone, Serialize)] pub struct SyncFreshnessView { pub state: String, pub display: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,8 +2,8 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, - LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, RelayListView, - SyncActionView, SyncStatusView, SyncWatchView, + FindView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, + RelayListView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -82,6 +82,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::Doctor(view) => { render_doctor(stdout, view)?; } + CommandView::Find(view) => { + render_find(stdout, view)?; + } CommandView::LocalBackup(view) => { render_local_backup(stdout, view)?; } @@ -184,6 +187,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::Find(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::LocalBackup(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -249,6 +256,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<() } Ok(()) } + CommandView::Find(view) => { + for result in &view.results { + serde_json::to_writer(&mut *stdout, result)?; + writeln!(stdout)?; + } + Ok(()) + } CommandView::SyncWatch(view) => { for frame in &view.frames { serde_json::to_writer(&mut *stdout, frame)?; @@ -408,6 +422,76 @@ fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), Runtim Ok(()) } +fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> { + let context = match view.state.as_str() { + "unconfigured" => "market · local first · unconfigured".to_owned(), + _ => format!( + "market · local first · {} result{}", + view.count, + if view.count == 1 { "" } else { "s" } + ), + }; + write_context(stdout, context.as_str())?; + writeln!(stdout, "query: {}", view.query)?; + + match view.state.as_str() { + "unconfigured" => { + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + } + _ if view.results.is_empty() => { + if let Some(reason) = &view.reason { + writeln!(stdout, "{reason}")?; + } + } + _ => { + let table = Table { + headers: &["product", "category", "price", "available", "location"], + rows: view + .results + .iter() + .map(|result| { + vec![ + result.title.clone(), + result.category.clone(), + format_price( + result.price.amount, + &result.price.currency, + result.price.per_amount, + &result.price.per_unit, + ), + 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()), + ), + result.location_primary.clone().unwrap_or_default(), + ] + }) + .collect(), + }; + render_table(stdout, &table)?; + } + } + + writeln!(stdout)?; + writeln!( + stdout, + "provenance: local replica · {} · {}", + view.freshness.display, + relay_count_text(view.relay_count) + )?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + fn render_relay_list(stdout: &mut dyn Write, view: &RelayListView) -> Result<(), RuntimeError> { write_context( stdout, @@ -874,6 +958,34 @@ fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeErro Ok(()) } +fn relay_count_text(count: usize) -> String { + if count == 1 { + "1 relay configured".to_owned() + } else { + format!("{count} relays configured") + } +} + +fn format_price(amount: f64, currency: &str, per_amount: u32, per_unit: &str) -> String { + format!( + "{} {currency}/{} {per_unit}", + trim_decimal(amount), + per_amount + ) +} + +fn format_available(amount: i64, unit: &str) -> String { + format!("{amount} {unit}") +} + +fn trim_decimal(value: f64) -> String { + let formatted = format!("{value:.2}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_owned() +} + struct Table { headers: &'static [&'static str], rows: Vec<Vec<String>>, @@ -887,6 +999,7 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::AccountWhoami(_) => "account whoami", CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", + CommandView::Find(_) => "find", CommandView::LocalBackup(_) => "local backup", CommandView::LocalExport(_) => "local export", CommandView::LocalInit(_) => "local init", diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -0,0 +1,162 @@ +use radroots_sql_core::{SqlExecutor, SqliteExecutor, utils}; +use serde::Deserialize; +use serde_json::Value; + +use crate::cli::FindArgs; +use crate::domain::runtime::{ + FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView, + SyncFreshnessView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::sync::freshness_from_executor; + +const FIND_SOURCE: &str = "local replica · local first"; + +#[derive(Debug, Clone, Deserialize)] +struct FindRow { + id: String, + key: String, + category: String, + title: String, + summary: String, + qty_amt: i64, + qty_unit: String, + qty_label: Option<String>, + qty_avail: Option<i64>, + price_amt: f64, + price_currency: String, + price_qty_amt: u32, + price_qty_unit: String, + location_primary: Option<String>, +} + +pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, RuntimeError> { + let query = args.query.join(" "); + if !config.local.replica_db_path.exists() { + return Ok(FindView { + state: "unconfigured".to_owned(), + source: FIND_SOURCE.to_owned(), + query, + count: 0, + relay_count: config.relay.urls.len(), + replica_db: config.local.replica_db_path.display().to_string(), + freshness: SyncFreshnessView { + state: "never".to_owned(), + display: "never synced".to_owned(), + age_seconds: None, + last_event_at: None, + }, + results: Vec::new(), + reason: Some("local replica database is not initialized".to_owned()), + actions: vec!["radroots local init".to_owned()], + }); + } + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let freshness = freshness_from_executor(&executor)?; + let rows = query_rows(&executor, &args.query)?; + let relay_count = config.relay.urls.len(); + let result_provenance = FindResultProvenanceView { + origin: "local_replica.trade_product".to_owned(), + freshness: freshness.display.clone(), + relay_count, + }; + let results = rows + .into_iter() + .map(|row| FindResultView { + id: row.id, + product_key: row.key, + title: row.title, + category: row.category, + summary: non_empty(row.summary), + location_primary: row.location_primary.and_then(non_empty), + available: FindQuantityView { + total_amount: row.qty_amt, + total_unit: row.qty_unit, + label: row.qty_label.and_then(non_empty), + available_amount: row.qty_avail, + }, + price: FindPriceView { + amount: row.price_amt, + currency: row.price_currency, + per_amount: row.price_qty_amt, + per_unit: row.price_qty_unit, + }, + provenance: result_provenance.clone(), + }) + .collect::<Vec<_>>(); + + let (state, reason, actions) = if results.is_empty() { + let actions = if freshness.state == "never" { + vec!["radroots sync status".to_owned()] + } else { + Vec::new() + }; + ( + "empty".to_owned(), + Some(format!("no local market results matched `{query}`")), + actions, + ) + } else { + ("ready".to_owned(), None, Vec::new()) + }; + + Ok(FindView { + state, + source: FIND_SOURCE.to_owned(), + query, + count: results.len(), + relay_count, + replica_db: config.local.replica_db_path.display().to_string(), + freshness, + results, + reason, + actions, + }) +} + +fn query_rows( + executor: &SqliteExecutor, + query_terms: &[String], +) -> Result<Vec<FindRow>, RuntimeError> { + let mut where_clauses = Vec::with_capacity(query_terms.len()); + let mut bind_values = Vec::<Value>::with_capacity(query_terms.len() * 5); + + for term in query_terms { + let pattern = format!("%{}%", term.to_lowercase()); + where_clauses.push( + "(lower(tp.title) LIKE ? OR lower(tp.summary) LIKE ? OR lower(tp.category) LIKE ? OR lower(tp.key) LIKE ? OR lower(COALESCE(tp.notes, '')) LIKE ?)" + .to_owned(), + ); + for _ in 0..5 { + bind_values.push(Value::from(pattern.clone())); + } + } + + let sql = format!( + "SELECT tp.id, tp.key, tp.category, tp.title, tp.summary, tp.qty_amt, tp.qty_unit, tp.qty_label, tp.qty_avail, tp.price_amt, tp.price_currency, tp.price_qty_amt, tp.price_qty_unit, loc.location_primary \ + FROM trade_product tp \ + LEFT JOIN (\ + SELECT tpl.tb_tp AS trade_product_id, MIN(COALESCE(gl.label, gl.gc_name, gl.gc_admin1_name, gl.gc_country_name, gl.d_tag)) AS location_primary \ + FROM trade_product_location tpl \ + JOIN gcs_location gl ON gl.id = tpl.tb_gl \ + GROUP BY tpl.tb_tp\ + ) loc ON loc.trade_product_id = tp.id \ + WHERE {} \ + ORDER BY lower(tp.title) ASC, tp.id ASC;", + where_clauses.join(" AND ") + ); + let params_json = utils::to_params_json(bind_values)?; + let raw = executor.query_raw(&sql, &params_json)?; + serde_json::from_str(&raw).map_err(RuntimeError::from) +} + +fn non_empty(value: String) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod config; +pub mod find; pub mod local; pub mod logging; pub mod myc; diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -212,7 +212,9 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { }) } -fn freshness_from_executor(executor: &SqliteExecutor) -> Result<SyncFreshnessView, RuntimeError> { +pub(crate) fn freshness_from_executor( + executor: &SqliteExecutor, +) -> Result<SyncFreshnessView, RuntimeError> { let raw = executor.query_raw( "SELECT MAX(last_created_at) AS last_created_at FROM nostr_event_state WHERE last_created_at IS NOT NULL", "[]", diff --git a/tests/find.rs b/tests/find.rs @@ -0,0 +1,251 @@ +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 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")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + ] { + command.env_remove(key); + } + command +} + +#[test] +fn find_reports_unconfigured_when_local_replica_is_missing() { + let dir = tempdir().expect("tempdir"); + let output = cli_command_in(dir.path()) + .args(["--json", "find", "eggs"]) + .output() + .expect("run find"); + + assert_eq!(output.status.code(), Some(3)); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "unconfigured"); + assert_eq!(json["actions"][0], "radroots local init"); +} + +#[test] +fn find_returns_json_and_ndjson_from_local_market_rows() { + 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-000000000101", + "heirloom-tomato", + "produce", + "Heirloom Tomato", + "Bright red slicing tomatoes", + 18, + 12, + Some("Asheville"), + ); + seed_trade_product( + dir.path(), + "00000000-0000-0000-0000-000000000102", + "tomato-sauce", + "prepared", + "Tomato Sauce", + "Slow cooked tomato sauce", + 8, + 6, + Some("Black Mountain"), + ); + + let json_output = cli_command_in(dir.path()) + .args(["--json", "find", "tomato"]) + .output() + .expect("run json find"); + 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"], 2); + assert_eq!( + json["results"][0]["provenance"]["origin"], + "local_replica.trade_product" + ); + assert_eq!(json["results"][0]["location_primary"], "Asheville"); + + let ndjson_output = cli_command_in(dir.path()) + .args(["--ndjson", "find", "tomato"]) + .output() + .expect("run ndjson find"); + 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(), 2); + assert!(lines[0].contains("\"title\":\"Heirloom Tomato\"")); + assert!(lines[1].contains("\"title\":\"Tomato Sauce\"")); +} + +#[test] +fn find_human_output_uses_market_table_and_provenance_footer() { + 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-000000000103", + "fresh-eggs", + "protein", + "Fresh Eggs", + "Pasture-raised eggs", + 36, + 24, + Some("Marshall"), + ); + + let output = cli_command_in(dir.path()) + .args(["find", "eggs"]) + .output() + .expect("run human find"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("market · local first · 1 result")); + assert!(stdout.contains("product")); + assert!(stdout.contains("Fresh Eggs")); + assert!(stdout.contains("provenance: local replica")); +} + +#[test] +fn find_reports_empty_results_without_failing() { + 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 output = cli_command_in(dir.path()) + .args(["--json", "find", "saffron"]) + .output() + .expect("run empty find"); + + assert!(output.status.success()); + let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json"); + assert_eq!(json["state"], "empty"); + assert_eq!(json["count"], 0); + assert!( + json["reason"] + .as_str() + .is_some_and(|reason| reason.contains("no local market results matched")) + ); +} + +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 = workdir + .join("home") + .join(".local/share/radroots/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", + "kg", + qty_avail, + 12.5, + "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"); + } +}