cli

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

commit 3ce80b0469570c8a10b56b94e76c76743eda9477
parent 354c1d06c55eff80f1a24e666ba231667f7e1059
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 09:26:00 +0000

cli: implement listing list

- discover canonical local listing draft files
- report listing summaries from parsed draft state
- route listing list through runtime-backed output
- cover empty and populated listing list flows

Diffstat:
Msrc/domain/runtime.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_listing.rs | 15++++++---------
Msrc/runtime/listing.rs | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/target_cli.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 348 insertions(+), 15 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1426,6 +1426,48 @@ pub struct ListingValidationIssueView { } #[derive(Debug, Clone, Serialize)] +pub struct ListingListView { + pub state: String, + pub source: String, + pub count: usize, + pub draft_dir: String, + pub listings: Vec<ListingSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl ListingListView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ListingSummaryView { + pub id: String, + pub state: String, + pub file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub product_key: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + 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 location_primary: Option<String>, + pub updated_at_unix: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec<ListingValidationIssueView>, +} + +#[derive(Debug, Clone, Serialize)] pub struct SellAddView { pub state: String, pub source: String, diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -91,16 +91,13 @@ impl OperationService<ListingListRequest> for ListingOperationService<'_> { fn execute( &self, - _request: OperationRequest<ListingListRequest>, + request: OperationRequest<ListingListRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - json_operation_result::<ListingListResult>(json!({ - "state": "empty", - "source": "local draft - local first", - "count": 0, - "listings": [], - "reason": null, - "actions": ["radroots listing create"], - })) + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::list(self.config), + )?; + serialized_operation_result::<ListingListResult, _>(&view) } } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -26,9 +26,9 @@ use radroots_trade::listing::validation::validate_listing_event; use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ - FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, + FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, - ListingValidateView, ListingValidationIssueView, SyncFreshnessView, + ListingSummaryView, ListingValidateView, ListingValidationIssueView, SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -47,6 +47,7 @@ const LISTING_SOURCE: &str = "local draft · local first"; const LISTING_READ_SOURCE: &str = "local replica · local first"; const LISTING_WRITE_SOURCE: &str = "daemon bridge · durable write plane"; const LISTING_LOCAL_SIGNED_SOURCE: &str = "local account signer · signed event artifact"; +const LISTING_DRAFTS_DIR: &str = "listings/drafts"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -162,6 +163,14 @@ struct CanonicalListingDraft { listing: RadrootsListing, } +#[derive(Debug, Clone)] +struct LoadedListingDraft { + file: PathBuf, + updated_at_unix: u64, + contents: String, + document: ListingDraftDocument, +} + #[derive(Debug, Clone, Copy)] pub enum ListingMutationOperation { Publish, @@ -184,7 +193,7 @@ pub fn scaffold( args: &ListingCreateArgs, ) -> Result<ListingNewView, RuntimeError> { let (draft, defaults) = build_listing_draft(config, args)?; - let output_path = default_listing_output_path(args.output.as_ref(), &draft.listing.d_tag)?; + let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; write_listing_draft(&output_path, &draft, false)?; let mut actions = vec![format!( @@ -280,13 +289,14 @@ fn build_listing_draft( Ok((draft, defaults)) } -fn default_listing_output_path( +fn listing_output_path( + config: &RuntimeConfig, explicit: Option<&std::path::PathBuf>, listing_id: &str, ) -> Result<std::path::PathBuf, RuntimeError> { match explicit { Some(path) => Ok(path.clone()), - None => Ok(std::env::current_dir()?.join(format!("listing-{listing_id}.toml"))), + None => Ok(drafts_dir(config).join(format!("{listing_id}.toml"))), } } @@ -393,6 +403,177 @@ pub fn validate( } } +pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { + let dir = drafts_dir(config); + if !dir.exists() { + return Ok(ListingListView { + state: "empty".to_owned(), + source: LISTING_SOURCE.to_owned(), + count: 0, + draft_dir: dir.display().to_string(), + listings: Vec::new(), + actions: vec!["radroots listing create".to_owned()], + }); + } + + let context = validation_context(config).map_err(|error| error.to_string()); + let mut listings = Vec::new(); + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("toml") { + continue; + } + match load_listing_draft(path.as_path()) { + Ok(loaded) => listings.push(summary_from_loaded(&loaded, context.as_ref())), + Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)), + } + } + + listings.sort_by(|left, right| { + right + .updated_at_unix + .cmp(&left.updated_at_unix) + .then_with(|| left.id.cmp(&right.id)) + }); + + let state = if listings.is_empty() { + "empty" + } else if listings.iter().any(|listing| listing.state == "error") { + "degraded" + } else { + "ready" + }; + let actions = if listings.is_empty() { + vec!["radroots listing create".to_owned()] + } else { + Vec::new() + }; + + Ok(ListingListView { + state: state.to_owned(), + source: LISTING_SOURCE.to_owned(), + count: listings.len(), + draft_dir: dir.display().to_string(), + listings, + actions, + }) +} + +fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> { + let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView { + field: "file".to_owned(), + message: format!("read listing draft {}: {error}", path.display()), + line: None, + })?; + let document = toml::from_str::<ListingDraftDocument>(contents.as_str()).map_err(|error| { + ListingValidationIssueView { + field: "toml".to_owned(), + message: error.to_string(), + line: error + .span() + .map(|span| line_for_offset(contents.as_str(), span.start + 1)), + } + })?; + Ok(LoadedListingDraft { + file: path.to_path_buf(), + updated_at_unix: modified_unix(path).unwrap_or_default(), + contents, + document, + }) +} + +fn summary_from_loaded( + loaded: &LoadedListingDraft, + context: Result<&ListingValidationContext, &String>, +) -> ListingSummaryView { + let mut seller_pubkey = non_empty(loaded.document.listing.seller_pubkey.clone()); + let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone()); + let mut issues = Vec::new(); + let mut state = "draft"; + + match context { + Ok(context) => { + match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) { + Ok(canonical) => { + seller_pubkey = Some(canonical.seller_pubkey.clone()); + farm_d_tag = Some(canonical.farm_d_tag.clone()); + issues = listing_ready_issues(&canonical, loaded.contents.as_str()); + if issues.is_empty() { + state = "ready"; + } + } + Err(issue) => issues.push(issue), + } + } + Err(reason) => issues.push(ListingValidationIssueView { + field: "context".to_owned(), + message: reason.to_string(), + line: None, + }), + } + + ListingSummaryView { + id: non_empty(loaded.document.listing.d_tag.clone()) + .unwrap_or_else(|| file_stem(loaded.file.as_path())), + state: state.to_owned(), + file: loaded.file.display().to_string(), + product_key: non_empty(loaded.document.product.key.clone()), + title: non_empty(loaded.document.product.title.clone()), + category: non_empty(loaded.document.product.category.clone()), + seller_pubkey, + farm_d_tag, + location_primary: non_empty(loaded.document.location.primary.clone()), + updated_at_unix: loaded.updated_at_unix, + issues, + } +} + +fn listing_ready_issues( + canonical: &CanonicalListingDraft, + contents: &str, +) -> Vec<ListingValidationIssueView> { + let parts = match to_wire_parts_with_kind(&canonical.listing, KIND_LISTING_DRAFT) { + Ok(parts) => parts, + Err(error) => { + return vec![ListingValidationIssueView { + field: "listing".to_owned(), + message: format!("invalid listing contract: {error}"), + line: None, + }]; + } + }; + let event = RadrootsNostrEvent { + id: String::new(), + author: canonical.seller_pubkey.clone(), + created_at: 0, + kind: KIND_LISTING_DRAFT, + tags: parts.tags, + content: parts.content, + sig: String::new(), + }; + match validate_listing_event(&event) { + Ok(_) => Vec::new(), + Err(error) => vec![issue_from_trade_validation(error, contents)], + } +} + +fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> ListingSummaryView { + ListingSummaryView { + id: file_stem(path), + state: "error".to_owned(), + file: path.display().to_string(), + product_key: None, + title: None, + category: None, + seller_pubkey: None, + farm_d_tag: None, + location_primary: None, + updated_at_unix: modified_unix(path).unwrap_or_default(), + issues: vec![issue], + } +} + pub fn get( config: &RuntimeConfig, args: &RecordLookupArgs, @@ -1513,6 +1694,25 @@ fn farm_setup_action(_config: &RuntimeConfig) -> Result<String, RuntimeError> { Ok("radroots farm create".to_owned()) } +fn drafts_dir(config: &RuntimeConfig) -> PathBuf { + config.paths.app_data_root.join(LISTING_DRAFTS_DIR) +} + +fn file_stem(path: &Path) -> String { + path.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_owned() +} + +fn modified_unix(path: &Path) -> Option<u64> { + let modified = fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .map(|value| value.as_secs()) +} + fn configured_account( config: &RuntimeConfig, account_id: &str, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -118,6 +118,7 @@ fn target_outputs_do_not_suggest_removed_command_families() { ["--format", "json", "market", "product", "search", "eggs"].as_slice(), ["--format", "json", "market", "listing", "get", "eggs"].as_slice(), ["--format", "json", "listing", "get", "eggs"].as_slice(), + ["--format", "json", "listing", "list"].as_slice(), ["--format", "json", "sync", "status", "get"].as_slice(), ["--format", "json", "runtime", "start"].as_slice(), [ @@ -135,6 +136,99 @@ fn target_outputs_do_not_suggest_removed_command_families() { } #[test] +fn listing_list_reports_empty_local_draft_state_truthfully() { + let sandbox = RadrootsCliSandbox::new(); + let value = sandbox.json_success(&["--format", "json", "listing", "list"]); + + assert_eq!(value["operation_id"], "listing.list"); + assert_eq!(value["result"]["state"], "empty"); + assert_eq!(value["result"]["count"], 0); + assert_eq!( + value["result"]["listings"] + .as_array() + .expect("listings") + .len(), + 0 + ); + assert!( + value["result"]["draft_dir"] + .as_str() + .expect("draft dir") + .ends_with("listings/drafts") + ); + assert_no_removed_command_reference(&value, &["listing", "list"]); +} + +#[test] +fn listing_list_reports_default_local_drafts() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + sandbox.json_success(&[ + "--format", + "json", + "farm", + "create", + "--name", + "Green Farm", + "--location", + "farmstand", + "--country", + "US", + "--delivery-method", + "pickup", + ]); + let create = sandbox.json_success(&[ + "--format", + "json", + "listing", + "create", + "--key", + "eggs", + "--title", + "Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + let listing_file = create["result"]["file"].as_str().expect("listing file"); + assert!(Path::new(listing_file).exists()); + + let value = sandbox.json_success(&["--format", "json", "listing", "list"]); + let listing = &value["result"]["listings"][0]; + + assert_eq!(value["operation_id"], "listing.list"); + assert_eq!(value["result"]["state"], "ready"); + assert_eq!(value["result"]["count"], 1); + assert_eq!(listing["id"], create["result"]["listing_id"]); + assert_eq!(listing["state"], "ready"); + assert_eq!(listing["file"], listing_file); + assert_eq!(listing["product_key"], "eggs"); + assert_eq!(listing["title"], "Eggs"); + assert_eq!(listing["category"], "eggs"); + assert_eq!(listing["location_primary"], "farmstand"); + assert!(listing["seller_pubkey"].is_string()); + assert!(listing["farm_d_tag"].is_string()); + assert_no_removed_command_reference(&value, &["listing", "list"]); +} + +#[test] fn account_id_global_populates_envelope_actor() { let output = radroots() .args([