cli

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

commit 91ee8322f6202d65dea09fcaf64e3e6aca1ea3e5
parent 2a72e73448585b211a02f6d11e1314885d7c669b
Author: triesap <tyson@radroots.org>
Date:   Fri,  8 May 2026 05:06:03 +0000

sync: ingest relay events

Diffstat:
Msrc/domain/runtime.rs | 2++
Msrc/operation_market.rs | 24+++++++++++++++++++++++-
Msrc/runtime/sync.rs | 638+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 632 insertions(+), 32 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2970,6 +2970,8 @@ pub struct SyncActionView { #[serde(skip_serializing_if = "Option::is_none")] pub unsupported_count: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] + pub failed_count: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec<String>, diff --git a/src/operation_market.rs b/src/operation_market.rs @@ -84,7 +84,7 @@ fn market_refresh_view(mut view: SyncActionView) -> SyncActionView { actions.push("radroots store init".to_owned()); } if view.relay_count == 0 { - actions.push("radroots relay list".to_owned()); + actions.push("radroots --relay wss://relay.example.com market refresh".to_owned()); } if actions.is_empty() { actions.extend(std::mem::take(&mut view.actions)); @@ -355,6 +355,28 @@ mod tests { } #[test] + fn market_refresh_no_relay_action_is_actionable() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + crate::runtime::local::init(&config).expect("store init"); + let service = OperationAdapter::new(MarketOperationService::new(&config)); + let request = + OperationRequest::new(OperationContext::default(), MarketRefreshRequest::default()) + .expect("market refresh request"); + let envelope = service + .execute(request) + .expect("market refresh result") + .to_envelope(OperationContext::default().envelope_context("req_market_refresh")) + .expect("market refresh envelope"); + + assert_eq!(envelope.result["state"], "unconfigured"); + assert_eq!( + envelope.result["actions"][0], + "radroots --relay wss://relay.example.com market refresh" + ); + } + + #[test] fn market_product_search_uses_find_runtime_without_top_level_find() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -1,13 +1,20 @@ use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_PROFILE}; +use radroots_events::kinds::{ + KIND_FARM, KIND_LIST_SET_APP_CURATION, KIND_LIST_SET_BOOKMARK, KIND_LIST_SET_CALENDAR, + KIND_LIST_SET_CURATION, KIND_LIST_SET_EMOJI, KIND_LIST_SET_FOLLOW, KIND_LIST_SET_GENERIC, + KIND_LIST_SET_INTEREST, KIND_LIST_SET_KIND_MUTE, KIND_LIST_SET_MEDIA_STARTER_PACK, + KIND_LIST_SET_PICTURE, KIND_LIST_SET_RELAY, KIND_LIST_SET_RELEASE_ARTIFACT, + KIND_LIST_SET_STARTER_PACK, KIND_LIST_SET_VIDEO, KIND_LISTING, KIND_PLOT, KIND_PROFILE, +}; use radroots_nostr::prelude::{ RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_kind, }; -use radroots_replica_db::ReplicaSql; +use radroots_replica_db::{ReplicaSql, migrations}; use radroots_replica_sync::{ - RadrootsReplicaIngestOutcome, radroots_replica_ingest_event, radroots_replica_sync_status, + RadrootsReplicaEventsError, RadrootsReplicaIngestOutcome, radroots_replica_ingest_event, + radroots_replica_sync_status, }; use radroots_sql_core::SqliteExecutor; @@ -18,12 +25,39 @@ use crate::domain::runtime::{ use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime::direct_relay::{ - DirectRelayFailure, DirectRelayFetchReceipt, fetch_events_from_relays, + DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, fetch_events_from_relays, }; use crate::runtime_args::SyncWatchArgs; const SYNC_SOURCE: &str = "local replica · local first"; -const RELAY_SETUP_ACTION: &str = "radroots --relay wss://relay.example.com relay list"; +const RELAY_SETUP_ACTION: &str = "radroots --relay wss://relay.example.com sync pull"; +const SYNC_PULL_ACTION: &str = "radroots sync pull"; +const SYNC_READY_ACTION: &str = "radroots market product search eggs"; +const MARKET_READY_ACTION: &str = "radroots market product search eggs"; +const INGEST_SOURCE: &str = "direct Nostr relay fetch · local replica ingest"; +const RELAY_FETCH_LIMIT: usize = 1_000; +const MARKET_REFRESH_KINDS: &[u32] = &[KIND_PROFILE, KIND_FARM, KIND_LISTING]; +const SYNC_PULL_KINDS: &[u32] = &[ + KIND_PROFILE, + KIND_FARM, + KIND_PLOT, + KIND_LISTING, + KIND_LIST_SET_FOLLOW, + KIND_LIST_SET_GENERIC, + KIND_LIST_SET_RELAY, + KIND_LIST_SET_BOOKMARK, + KIND_LIST_SET_CURATION, + KIND_LIST_SET_VIDEO, + KIND_LIST_SET_PICTURE, + KIND_LIST_SET_KIND_MUTE, + KIND_LIST_SET_INTEREST, + KIND_LIST_SET_EMOJI, + KIND_LIST_SET_RELEASE_ARTIFACT, + KIND_LIST_SET_APP_CURATION, + KIND_LIST_SET_CALENDAR, + KIND_LIST_SET_STARTER_PACK, + KIND_LIST_SET_MEDIA_STARTER_PACK, +]; #[derive(Debug, Clone)] struct SyncSnapshot { @@ -56,14 +90,47 @@ pub fn status(config: &RuntimeConfig) -> Result<SyncStatusView, RuntimeError> { } pub fn pull(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeError> { - narrowed_action( - config, - "pull", - "relay ingest is not wired into `radroots sync pull` yet", - ) + pull_with_fetcher(config, fetch_events_from_relays) } pub fn market_refresh(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeError> { + market_refresh_with_fetcher(config, fetch_events_from_relays) +} + +fn pull_with_fetcher<F>(config: &RuntimeConfig, fetcher: F) -> Result<SyncActionView, RuntimeError> +where + F: FnOnce( + &[String], + RadrootsNostrFilter, + ) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError>, +{ + relay_ingest(config, RelayIngestScope::SyncPull, fetcher) +} + +fn market_refresh_with_fetcher<F>( + config: &RuntimeConfig, + fetcher: F, +) -> Result<SyncActionView, RuntimeError> +where + F: FnOnce( + &[String], + RadrootsNostrFilter, + ) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError>, +{ + relay_ingest(config, RelayIngestScope::MarketRefresh, fetcher) +} + +fn relay_ingest<F>( + config: &RuntimeConfig, + scope: RelayIngestScope, + fetcher: F, +) -> Result<SyncActionView, RuntimeError> +where + F: FnOnce( + &[String], + RadrootsNostrFilter, + ) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError>, +{ let snapshot = inspect_sync(config)?; if snapshot.state == "unconfigured" { return Ok(empty_action_from_snapshot(snapshot, "pull")); @@ -78,11 +145,25 @@ pub fn market_refresh(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeE view.ingested_count = Some(0); view.skipped_count = Some(0); view.unsupported_count = Some(0); + view.failed_count = Some(0); + view.actions = vec![scope.ready_action().to_owned()]; return Ok(view); } - let receipt = match fetch_events_from_relays(&config.relay.urls, market_refresh_filter()) { + let receipt = match fetcher(&config.relay.urls, scope.filter()) { Ok(receipt) => receipt, + Err(DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + }) => { + let mut view = empty_action_from_snapshot(snapshot, "pull"); + view.state = "unavailable".to_owned(); + view.reason = Some(format!("direct relay connection failed: {reason}")); + view.target_relays = target_relays; + view.failed_relays = relay_failures(failed_relays); + return Ok(view); + } Err(error) => { let mut view = empty_action_from_snapshot(snapshot, "pull"); view.state = "unavailable".to_owned(); @@ -93,14 +174,15 @@ pub fn market_refresh(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeE }; let executor = SqliteExecutor::open(&config.local.replica_db_path)?; - let ingest = ingest_market_events(&executor, &receipt)?; + migrations::run_all_up(&executor)?; + let ingest = ingest_events(&executor, &receipt, scope)?; let freshness = freshness_from_executor(&executor)?; let queue = radroots_replica_sync_status(&executor)?; Ok(SyncActionView { direction: "pull".to_owned(), state: "ready".to_owned(), - source: "direct Nostr relay fetch · local replica ingest".to_owned(), + source: INGEST_SOURCE.to_owned(), local_root: config.local.root.display().to_string(), replica_db: "ready".to_owned(), relay_count: config.relay.urls.len(), @@ -117,8 +199,9 @@ pub fn market_refresh(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeE ingested_count: Some(ingest.ingested_count), skipped_count: Some(ingest.skipped_count), unsupported_count: Some(ingest.unsupported_count), - reason: None, - actions: vec!["radroots market product search eggs".to_owned()], + failed_count: Some(ingest.failed_count), + reason: ingest.reason(), + actions: vec![scope.ready_action().to_owned()], }) } @@ -198,6 +281,7 @@ fn narrowed_action( ingested_count: None, skipped_count: None, unsupported_count: None, + failed_count: None, reason: Some(unavailable_reason.to_owned()), actions, }) @@ -221,6 +305,7 @@ fn empty_action_from_snapshot(snapshot: SyncSnapshot, direction: &str) -> SyncAc ingested_count: None, skipped_count: None, unsupported_count: None, + failed_count: None, reason: snapshot.reason, actions: snapshot.actions, } @@ -276,7 +361,7 @@ fn inspect_sync(config: &RuntimeConfig) -> Result<SyncSnapshot, RuntimeError> { }); } - actions.push("radroots sync pull".to_owned()); + actions.push(SYNC_PULL_ACTION.to_owned()); if queue.pending_count > 0 { actions.push("radroots sync push".to_owned()); } @@ -323,45 +408,101 @@ pub(crate) fn freshness_from_executor( }) } -#[derive(Debug, Clone, Copy, Default)] -struct MarketIngestCounts { +#[derive(Debug, Clone, Default)] +struct RelayIngestCounts { fetched_count: usize, ingested_count: usize, skipped_count: usize, unsupported_count: usize, + failed_count: usize, + first_failure_reason: Option<String>, +} + +impl RelayIngestCounts { + fn reason(&self) -> Option<String> { + (self.failed_count > 0).then(|| match &self.first_failure_reason { + Some(reason) => format!( + "{} fetched event(s) failed ingest: {}", + self.failed_count, reason + ), + None => format!("{} fetched event(s) failed ingest", self.failed_count), + }) + } } -fn market_refresh_filter() -> RadrootsNostrFilter { - RadrootsNostrFilter::new() - .kinds([ - radroots_nostr_kind(KIND_PROFILE as u16), - radroots_nostr_kind(KIND_FARM as u16), - radroots_nostr_kind(KIND_LISTING as u16), - ]) - .limit(1_000) +#[derive(Debug, Clone, Copy)] +enum RelayIngestScope { + SyncPull, + MarketRefresh, } -fn ingest_market_events( +impl RelayIngestScope { + fn kinds(self) -> &'static [u32] { + match self { + Self::SyncPull => SYNC_PULL_KINDS, + Self::MarketRefresh => MARKET_REFRESH_KINDS, + } + } + + fn filter(self) -> RadrootsNostrFilter { + RadrootsNostrFilter::new() + .kinds( + self.kinds() + .iter() + .copied() + .map(|kind| radroots_nostr_kind(kind as u16)), + ) + .limit(RELAY_FETCH_LIMIT) + } + + fn ready_action(self) -> &'static str { + match self { + Self::SyncPull => SYNC_READY_ACTION, + Self::MarketRefresh => MARKET_READY_ACTION, + } + } + + fn supports_kind(self, kind: u32) -> bool { + self.kinds().contains(&kind) + } +} + +fn ingest_events( executor: &SqliteExecutor, receipt: &DirectRelayFetchReceipt, -) -> Result<MarketIngestCounts, RuntimeError> { - let mut counts = MarketIngestCounts { + scope: RelayIngestScope, +) -> Result<RelayIngestCounts, RuntimeError> { + let mut counts = RelayIngestCounts { fetched_count: receipt.events.len(), - ..MarketIngestCounts::default() + ..RelayIngestCounts::default() }; for event in &receipt.events { + if !scope.supports_kind(event_kind(event)) { + counts.unsupported_count += 1; + continue; + } let event = radroots_event_from_nostr(event); match radroots_replica_ingest_event(executor, &event) { Ok(RadrootsReplicaIngestOutcome::Applied) => counts.ingested_count += 1, Ok(RadrootsReplicaIngestOutcome::Skipped) => counts.skipped_count += 1, - Err(_) => counts.unsupported_count += 1, + Err(error @ RadrootsReplicaEventsError::Sql(_)) => return Err(error.into()), + Err(error) => { + counts.failed_count += 1; + if counts.first_failure_reason.is_none() { + counts.first_failure_reason = Some(error.to_string()); + } + } } } Ok(counts) } +fn event_kind(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> u32 { + u32::from(event.kind.as_u16()) +} + fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { failures .into_iter() @@ -388,3 +529,438 @@ fn relative_age(age_seconds: u64) -> String { _ => format!("{}d ago", age_seconds / 86_400), } } + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use radroots_events::farm::{RadrootsFarm, RadrootsFarmRef}; + use radroots_events::kinds::{KIND_FARM, KIND_LIST_SET_GENERIC, KIND_LISTING, KIND_POST}; + use radroots_events::list::RadrootsListEntry; + use radroots_events::list_set::RadrootsListSet; + use radroots_events::plot::RadrootsPlot; + use radroots_events::profile::{RadrootsProfile, RadrootsProfileType}; + use radroots_events_codec::farm::encode as farm_encode; + use radroots_events_codec::list_set::encode as list_set_encode; + use radroots_events_codec::plot::encode as plot_encode; + use radroots_events_codec::profile::encode as profile_encode; + use radroots_events_codec::wire::WireEventParts; + use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::{ + RadrootsNostrEvent, RadrootsNostrFilter, radroots_nostr_build_event, + }; + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + use tempfile::tempdir; + + use super::{ + DirectRelayFetchError, DirectRelayFetchReceipt, market_refresh_with_fetcher, + pull_with_fetcher, + }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, + RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, + }; + use crate::runtime_args::{FindQueryArgs, RecordLookupArgs}; + + const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + const PLOT_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; + const LISTING_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; + + #[test] + fn sync_pull_dry_run_skips_relay_fetch() { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + config.output.dry_run = true; + crate::runtime::local::init(&config).expect("store init"); + + let view = pull_with_fetcher(&config, |_, _| panic!("dry run must not fetch")) + .expect("sync pull dry run"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.target_relays, vec!["wss://relay.example.com"]); + assert_eq!(view.fetched_count, Some(0)); + assert_eq!(view.ingested_count, Some(0)); + assert_eq!(view.skipped_count, Some(0)); + assert_eq!(view.unsupported_count, Some(0)); + assert_eq!(view.failed_count, Some(0)); + } + + #[test] + fn sync_pull_no_relay_action_is_actionable() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), Vec::new()); + crate::runtime::local::init(&config).expect("store init"); + + let view = pull_with_fetcher(&config, |_, _| { + panic!("unconfigured sync pull must not fetch") + }) + .expect("sync pull unconfigured"); + + assert_eq!(view.state, "unconfigured"); + assert_eq!( + view.actions, + vec!["radroots --relay wss://relay.example.com sync pull"] + ); + } + + #[test] + fn sync_pull_ingests_relay_events_and_market_reads_without_daemon() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::local::init(&config).expect("store init"); + let seller = identity(7); + let seller_pubkey = seller.public_key_hex(); + let listing_addr = format!("{KIND_LISTING}:{seller_pubkey}:{LISTING_D_TAG}"); + let events = vec![ + profile_event(&seller), + farm_event(&seller), + plot_event(&seller), + listing_event(&seller), + list_set_event(&seller), + ]; + + let view = pull_with_fetcher(&config, fake_fetcher(events)).expect("sync pull ingest"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.fetched_count, Some(5)); + assert_eq!(view.ingested_count, Some(5)); + assert_eq!(view.skipped_count, Some(0)); + assert_eq!(view.unsupported_count, Some(0)); + assert_eq!(view.failed_count, Some(0)); + assert_eq!(view.reason, None); + + let search = crate::runtime::find::search( + &config, + &FindQueryArgs { + query: vec!["eggs".to_owned()], + }, + ) + .expect("market search"); + assert_eq!(search.state, "ready"); + assert_eq!(search.count, 1); + assert_eq!( + search.results[0].listing_addr.as_deref(), + Some(listing_addr.as_str()) + ); + + let listing = crate::runtime::listing::get( + &config, + &RecordLookupArgs { + key: "pasture-eggs".to_owned(), + }, + ) + .expect("listing get"); + assert_eq!(listing.state, "ready"); + assert_eq!(listing.listing_addr.as_deref(), Some(listing_addr.as_str())); + } + + #[test] + fn market_refresh_uses_market_scope_for_ingest() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::local::init(&config).expect("store init"); + let seller = identity(8); + let events = vec![listing_event(&seller), plot_event(&seller)]; + + let view = + market_refresh_with_fetcher(&config, fake_fetcher(events)).expect("market refresh"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.fetched_count, Some(2)); + assert_eq!(view.ingested_count, Some(1)); + assert_eq!(view.unsupported_count, Some(1)); + assert_eq!(view.failed_count, Some(0)); + } + + #[test] + fn relay_ingest_splits_unsupported_and_failed_events() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path(), vec!["wss://relay.example.com".to_owned()]); + crate::runtime::local::init(&config).expect("store init"); + let seller = identity(9); + let events = vec![ + signed_event( + &seller, + WireEventParts { + kind: KIND_POST, + content: "hello".to_owned(), + tags: Vec::new(), + }, + ), + signed_event( + &seller, + WireEventParts { + kind: KIND_LISTING, + content: "not a listing".to_owned(), + tags: Vec::new(), + }, + ), + ]; + + let view = pull_with_fetcher(&config, fake_fetcher(events)).expect("sync pull ingest"); + + assert_eq!(view.state, "ready"); + assert_eq!(view.fetched_count, Some(2)); + assert_eq!(view.ingested_count, Some(0)); + assert_eq!(view.unsupported_count, Some(1)); + assert_eq!(view.failed_count, Some(1)); + assert!( + view.reason + .as_deref() + .expect("failure reason") + .contains("failed ingest") + ); + } + + fn fake_fetcher( + events: Vec<RadrootsNostrEvent>, + ) -> impl FnOnce( + &[String], + RadrootsNostrFilter, + ) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError> { + move |relays, _| { + Ok(DirectRelayFetchReceipt { + target_relays: relays.to_vec(), + connected_relays: relays.to_vec(), + failed_relays: Vec::new(), + events, + }) + } + } + + fn profile_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { + let profile = RadrootsProfile { + name: "seller".to_owned(), + display_name: Some("Seller".to_owned()), + nip05: None, + about: Some("market seller".to_owned()), + website: Some("https://seller.example.com".to_owned()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + }; + signed_event( + identity, + profile_encode::to_wire_parts_with_profile_type( + &profile, + Some(RadrootsProfileType::Farm), + ) + .expect("profile parts"), + ) + } + + fn farm_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { + let farm = RadrootsFarm { + d_tag: FARM_D_TAG.to_owned(), + name: "Relay Farm".to_owned(), + about: Some("relay farm".to_owned()), + website: Some("https://farm.example.com".to_owned()), + picture: None, + banner: None, + location: None, + tags: None, + }; + signed_event( + identity, + farm_encode::to_wire_parts(&farm).expect("farm parts"), + ) + } + + fn plot_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { + let plot = RadrootsPlot { + d_tag: PLOT_D_TAG.to_owned(), + farm: RadrootsFarmRef { + pubkey: identity.public_key_hex(), + d_tag: FARM_D_TAG.to_owned(), + }, + name: "Relay Plot".to_owned(), + about: Some("relay plot".to_owned()), + location: None, + tags: None, + }; + signed_event( + identity, + plot_encode::to_wire_parts(&plot).expect("plot parts"), + ) + } + + fn list_set_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { + let list_set = RadrootsListSet { + d_tag: "member_of.farms".to_owned(), + content: String::new(), + entries: vec![RadrootsListEntry { + tag: "p".to_owned(), + values: vec![identity.public_key_hex()], + }], + title: None, + description: None, + image: None, + }; + signed_event( + identity, + list_set_encode::to_wire_parts_with_kind(&list_set, KIND_LIST_SET_GENERIC) + .expect("list set parts"), + ) + } + + fn listing_event(identity: &RadrootsIdentity) -> RadrootsNostrEvent { + signed_event( + identity, + WireEventParts { + kind: KIND_LISTING, + tags: vec![ + vec!["d".to_owned(), LISTING_D_TAG.to_owned()], + vec![ + "a".to_owned(), + format!("{}:{}:{}", KIND_FARM, identity.public_key_hex(), FARM_D_TAG), + ], + vec!["p".to_owned(), identity.public_key_hex()], + vec!["key".to_owned(), "pasture-eggs".to_owned()], + vec!["title".to_owned(), "Pasture Eggs".to_owned()], + vec!["category".to_owned(), "eggs".to_owned()], + vec!["summary".to_owned(), "Pasture-raised eggs".to_owned()], + vec!["process".to_owned(), "washed".to_owned()], + vec!["lot".to_owned(), "lot-a".to_owned()], + vec!["profile".to_owned(), "dozen".to_owned()], + vec!["year".to_owned(), "2026".to_owned()], + vec!["radroots:primary_bin".to_owned(), "bin-a".to_owned()], + vec![ + "radroots:bin".to_owned(), + "bin-a".to_owned(), + "12".to_owned(), + "each".to_owned(), + "12".to_owned(), + "each".to_owned(), + "dozen".to_owned(), + ], + vec![ + "radroots:price".to_owned(), + "bin-a".to_owned(), + "6".to_owned(), + "USD".to_owned(), + "1".to_owned(), + "each".to_owned(), + "6".to_owned(), + "each".to_owned(), + ], + vec!["inventory".to_owned(), "5".to_owned()], + vec!["status".to_owned(), "active".to_owned()], + ], + content: "# Pasture Eggs".to_owned(), + }, + ) + } + + fn signed_event(identity: &RadrootsIdentity, parts: WireEventParts) -> RadrootsNostrEvent { + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("event builder") + .sign_with_keys(identity.keys()) + .expect("signed event") + } + + fn identity(seed: u8) -> RadrootsIdentity { + RadrootsIdentity::from_secret_key_bytes(&[seed; 32]).expect("identity") + } + + fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + publish: PublishConfig { + mode: PublishMode::NostrRelay, + source: PublishModeSource::Defaults, + }, + relay: RelayConfig { + urls: relays, + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } +}