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:
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([