cli

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

commit 9e4a5542e5e7a51b63a85d330f2077dd899b48e2
parent 4205ebd96701352446a0383636305b2bfcd4feb5
Author: triesap <tyson@radroots.org>
Date:   Wed, 15 Apr 2026 21:26:18 +0000

cli: add scoped farm config resolution

Diffstat:
Asrc/runtime/farm_config.rs | 452+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/listing.rs | 15++++++++-------
Msrc/runtime/mod.rs | 2++
3 files changed, 462 insertions(+), 7 deletions(-)

diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs @@ -0,0 +1,452 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use radroots_events::farm::RadrootsFarm; +use radroots_events::listing::{RadrootsListingDeliveryMethod, RadrootsListingLocation}; +use radroots_events::profile::RadrootsProfile; +use serde::{Deserialize, Serialize}; + +use crate::runtime::RuntimeError; +use crate::runtime::config::{PathsConfig, RuntimeConfig}; + +const FARM_CONFIG_FILE_NAME: &str = "farm.toml"; +const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FarmConfigScope { + User, + Workspace, +} + +impl FarmConfigScope { + pub fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::Workspace => "workspace", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FarmConfigDocument { + pub version: u32, + pub selection: FarmConfigSelection, + pub profile: RadrootsProfile, + pub farm: RadrootsFarm, + pub listing_defaults: FarmListingDefaults, + #[serde(default)] + pub publication: FarmPublicationStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FarmConfigSelection { + pub scope: FarmConfigScope, + pub account: String, + pub farm_d_tag: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FarmListingDefaults { + pub delivery_method: String, + pub location: RadrootsListingLocation, +} + +impl FarmListingDefaults { + pub fn delivery_method_model(&self) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { + parse_delivery_method(self.delivery_method.as_str()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FarmPublicationStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_event_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub farm_event_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_published_at: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub farm_published_at: Option<u64>, +} + +#[derive(Debug, Clone)] +pub struct ResolvedFarmConfig { + pub scope: FarmConfigScope, + pub path: PathBuf, + pub document: FarmConfigDocument, +} + +pub fn resolve_scope( + paths: &PathsConfig, + explicit_scope: Option<FarmConfigScope>, +) -> Result<FarmConfigScope, RuntimeError> { + if let Some(scope) = explicit_scope { + return Ok(scope); + } + match paths.profile.as_str() { + "repo_local" => Ok(FarmConfigScope::Workspace), + "interactive_user" => Ok(FarmConfigScope::User), + other => Err(RuntimeError::Config(format!( + "unsupported farm config path profile `{other}`" + ))), + } +} + +pub fn user_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { + let Some(parent) = paths.app_config_path.parent() else { + return Err(RuntimeError::Config(format!( + "app config path {} has no parent directory", + paths.app_config_path.display() + ))); + }; + Ok(parent.join(FARM_CONFIG_FILE_NAME)) +} + +pub fn workspace_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { + let Some(parent) = paths.workspace_config_path.parent() else { + return Err(RuntimeError::Config(format!( + "workspace config path {} has no parent directory", + paths.workspace_config_path.display() + ))); + }; + Ok(parent.join("config/apps/cli").join(FARM_CONFIG_FILE_NAME)) +} + +pub fn config_path(paths: &PathsConfig, scope: FarmConfigScope) -> Result<PathBuf, RuntimeError> { + match scope { + FarmConfigScope::User => user_config_path(paths), + FarmConfigScope::Workspace => workspace_config_path(paths), + } +} + +pub fn load( + config: &RuntimeConfig, + explicit_scope: Option<FarmConfigScope>, +) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { + load_from_paths(&config.paths, explicit_scope) +} + +pub fn load_from_paths( + paths: &PathsConfig, + explicit_scope: Option<FarmConfigScope>, +) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { + let scope = resolve_scope(paths, explicit_scope)?; + let path = config_path(paths, scope)?; + load_from_path(path.as_path(), scope) +} + +pub fn load_from_path( + path: &Path, + scope: FarmConfigScope, +) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { + if !path.exists() { + return Ok(None); + } + let contents = fs::read_to_string(path)?; + let document: FarmConfigDocument = toml::from_str(contents.as_str()).map_err(|error| { + RuntimeError::Config(format!("parse farm config {}: {error}", path.display())) + })?; + validate(&document, scope)?; + Ok(Some(ResolvedFarmConfig { + scope, + path: path.to_path_buf(), + document, + })) +} + +pub fn write( + paths: &PathsConfig, + scope: FarmConfigScope, + document: &FarmConfigDocument, +) -> Result<PathBuf, RuntimeError> { + validate(document, scope)?; + let path = config_path(paths, scope)?; + let Some(parent) = path.parent() else { + return Err(RuntimeError::Config(format!( + "farm config path {} has no parent directory", + path.display() + ))); + }; + fs::create_dir_all(parent)?; + let encoded = toml::to_string_pretty(document).map_err(|error| { + RuntimeError::Config(format!("encode farm config {}: {error}", path.display())) + })?; + fs::write(&path, encoded)?; + Ok(path) +} + +pub fn validate( + document: &FarmConfigDocument, + resolved_scope: FarmConfigScope, +) -> Result<(), RuntimeError> { + if document.version != SUPPORTED_FARM_CONFIG_VERSION { + return Err(RuntimeError::Config(format!( + "farm config version must be {}, got {}", + SUPPORTED_FARM_CONFIG_VERSION, document.version + ))); + } + if document.selection.scope != resolved_scope { + return Err(RuntimeError::Config(format!( + "farm config scope `{}` does not match resolved `{}` scope", + document.selection.scope.as_str(), + resolved_scope.as_str() + ))); + } + if trimmed(document.selection.account.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config selection.account must not be empty".to_owned(), + )); + } + if trimmed(document.selection.farm_d_tag.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config selection.farm_d_tag must not be empty".to_owned(), + )); + } + if trimmed(document.profile.name.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config profile.name must not be empty".to_owned(), + )); + } + if trimmed(document.farm.d_tag.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config farm.d_tag must not be empty".to_owned(), + )); + } + if trimmed(document.farm.name.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config farm.name must not be empty".to_owned(), + )); + } + if trimmed(document.selection.farm_d_tag.as_str()) != trimmed(document.farm.d_tag.as_str()) { + return Err(RuntimeError::Config( + "farm config selection.farm_d_tag must match farm.d_tag".to_owned(), + )); + } + let _ = document.listing_defaults.delivery_method_model()?; + if trimmed(document.listing_defaults.location.primary.as_str()).is_empty() { + return Err(RuntimeError::Config( + "farm config listing_defaults.location.primary must not be empty".to_owned(), + )); + } + Ok(()) +} + +fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { + let method = trimmed(value); + if method.is_empty() { + return Err(RuntimeError::Config( + "farm config listing_defaults.delivery_method must not be empty".to_owned(), + )); + } + Ok(match method { + "pickup" => RadrootsListingDeliveryMethod::Pickup, + "local_delivery" => RadrootsListingDeliveryMethod::LocalDelivery, + "shipping" => RadrootsListingDeliveryMethod::Shipping, + other => RadrootsListingDeliveryMethod::Other { + method: other.to_owned(), + }, + }) +} + +fn trimmed(value: &str) -> &str { + value.trim() +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + use radroots_events::farm::RadrootsFarmLocation; + use tempfile::tempdir; + + fn sample_paths(profile: &str, root: &Path) -> PathsConfig { + PathsConfig { + profile: profile.to_owned(), + profile_source: "test".to_owned(), + allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned()], + root_source: "test".to_owned(), + repo_local_root: Some(root.join(".radroots")), + repo_local_root_source: Some("test".to_owned()), + subordinate_path_override_source: "test".to_owned(), + app_namespace: "apps/cli".to_owned(), + shared_accounts_namespace: "shared/accounts".to_owned(), + shared_identities_namespace: "shared/identities".to_owned(), + app_config_path: root.join("home/.radroots/config/apps/cli/config.toml"), + workspace_config_path: root.join("workspace/.radroots/config.toml"), + app_data_root: root.join("home/.radroots/data/apps/cli"), + app_logs_root: root.join("home/.radroots/logs/apps/cli"), + shared_accounts_data_root: root.join("home/.radroots/data/shared/accounts"), + shared_accounts_secrets_root: root.join("home/.radroots/secrets/shared/accounts"), + default_identity_path: root + .join("home/.radroots/secrets/shared/identities/default.json"), + } + } + + fn sample_document(scope: FarmConfigScope) -> FarmConfigDocument { + FarmConfigDocument { + version: SUPPORTED_FARM_CONFIG_VERSION, + selection: FarmConfigSelection { + scope, + account: "seller".to_owned(), + farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + }, + profile: RadrootsProfile { + name: "La Huerta".to_owned(), + display_name: Some("La Huerta".to_owned()), + nip05: None, + about: Some("Small mixed vegetable farm.".to_owned()), + website: Some("https://example.invalid/la-huerta".to_owned()), + picture: None, + banner: None, + lud06: None, + lud16: None, + bot: None, + }, + farm: RadrootsFarm { + d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), + name: "La Huerta".to_owned(), + about: Some("Small mixed vegetable farm.".to_owned()), + website: Some("https://example.invalid/la-huerta".to_owned()), + picture: None, + banner: None, + location: Some(RadrootsFarmLocation { + primary: Some("San Francisco, CA".to_owned()), + city: Some("San Francisco".to_owned()), + region: Some("CA".to_owned()), + country: Some("US".to_owned()), + gcs: None, + }), + tags: None, + }, + listing_defaults: FarmListingDefaults { + delivery_method: "pickup".to_owned(), + location: RadrootsListingLocation { + primary: "San Francisco, CA".to_owned(), + city: Some("San Francisco".to_owned()), + region: Some("CA".to_owned()), + country: Some("US".to_owned()), + lat: None, + lng: None, + geohash: None, + }, + }, + publication: FarmPublicationStatus::default(), + } + } + + #[test] + fn resolve_scope_defaults_from_runtime_profile() { + let dir = tempdir().expect("tempdir"); + let interactive_paths = sample_paths("interactive_user", dir.path()); + let repo_local_paths = sample_paths("repo_local", dir.path()); + + assert_eq!( + resolve_scope(&interactive_paths, None).expect("interactive scope"), + FarmConfigScope::User + ); + assert_eq!( + resolve_scope(&repo_local_paths, None).expect("repo_local scope"), + FarmConfigScope::Workspace + ); + } + + #[test] + fn explicit_scope_override_selects_requested_document() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths("repo_local", dir.path()); + let document = sample_document(FarmConfigScope::User); + let path = write(&paths, FarmConfigScope::User, &document).expect("write user farm config"); + + let resolved = + load_from_paths(&paths, Some(FarmConfigScope::User)).expect("load user farm config"); + let resolved = resolved.expect("resolved farm config"); + + assert_eq!(resolved.scope, FarmConfigScope::User); + assert_eq!(resolved.path, path); + assert_eq!(resolved.document.selection.account, "seller"); + assert_eq!(resolved.document.selection.scope, FarmConfigScope::User); + } + + #[test] + fn write_and_load_workspace_config_round_trip() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths("repo_local", dir.path()); + let document = sample_document(FarmConfigScope::Workspace); + let expected_path = + PathBuf::from(dir.path()).join("workspace/.radroots/config/apps/cli/farm.toml"); + + let written_path = + write(&paths, FarmConfigScope::Workspace, &document).expect("write workspace config"); + let resolved = load_from_paths(&paths, None).expect("load workspace config"); + let resolved = resolved.expect("resolved farm config"); + + assert_eq!(written_path, expected_path); + assert_eq!(resolved.path, expected_path); + assert_eq!(resolved.scope, FarmConfigScope::Workspace); + assert_eq!( + resolved.document.selection.scope, + FarmConfigScope::Workspace + ); + assert_eq!( + resolved.document.selection.farm_d_tag, + "AAAAAAAAAAAAAAAAAAAAAA" + ); + assert_eq!(resolved.document.farm.d_tag, "AAAAAAAAAAAAAAAAAAAAAA"); + assert_eq!( + resolved.document.listing_defaults.location.primary, + "San Francisco, CA" + ); + } + + #[test] + fn load_rejects_scope_mismatch() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths("repo_local", dir.path()); + let path = workspace_config_path(&paths).expect("workspace farm path"); + let Some(parent) = path.parent() else { + panic!("workspace farm path should have parent"); + }; + fs::create_dir_all(parent).expect("create workspace farm config dir"); + let contents = toml::to_string_pretty(&sample_document(FarmConfigScope::User)) + .expect("encode mismatched farm config"); + fs::write(&path, contents).expect("write mismatched farm config"); + + let error = load_from_paths(&paths, None).expect_err("scope mismatch should fail"); + match error { + RuntimeError::Config(message) => { + assert!(message.contains("does not match resolved `workspace` scope")); + } + other => panic!("expected config error, got {other:?}"), + } + } + + #[test] + fn load_rejects_unsupported_version() { + let dir = tempdir().expect("tempdir"); + let paths = sample_paths("interactive_user", dir.path()); + let path = user_config_path(&paths).expect("user farm path"); + let Some(parent) = path.parent() else { + panic!("user farm path should have parent"); + }; + fs::create_dir_all(parent).expect("create user farm config dir"); + let mut document = sample_document(FarmConfigScope::User); + document.version = 2; + let contents = toml::to_string_pretty(&document).expect("encode version mismatch"); + fs::write(&path, contents).expect("write version mismatch config"); + + let error = load_from_paths(&paths, None).expect_err("version mismatch should fail"); + match error { + RuntimeError::Config(message) => { + assert!(message.contains("farm config version must be 1, got 2")); + } + other => panic!("expected config error, got {other:?}"), + } + } +} diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -7,14 +7,15 @@ use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit, }; +use radroots_events::RadrootsNostrEvent; +use radroots_events::farm::RadrootsFarmRef; use radroots_events::kinds::{KIND_LISTING, KIND_LISTING_DRAFT}; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingFarmRef, RadrootsListingLocation, - RadrootsListingProduct, RadrootsListingStatus, + RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, + RadrootsListingStatus, }; use radroots_events::trade::RadrootsTradeListingValidationError; -use radroots_events::RadrootsNostrEvent; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_replica_db::ReplicaSql; @@ -29,13 +30,13 @@ use crate::domain::runtime::{ ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, ListingValidateView, ListingValidationIssueView, SyncFreshnessView, }; +use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon; use crate::runtime::daemon::DaemonRpcError; -use crate::runtime::signer::{resolve_actor_write_authority, ActorWriteBindingError}; +use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; use crate::runtime::sync::freshness_from_executor; -use crate::runtime::RuntimeError; const DRAFT_KIND: &str = "listing_draft_v1"; const LISTING_SOURCE: &str = "local draft ยท local first"; @@ -791,7 +792,7 @@ fn canonicalize_draft( let listing = RadrootsListing { d_tag: listing_id.clone(), - farm: RadrootsListingFarmRef { + farm: RadrootsFarmRef { pubkey: seller_pubkey.clone(), d_tag: farm_d_tag.clone(), }, @@ -1355,7 +1356,7 @@ fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { #[cfg(test)] mod tests { - use super::{encode_base64url_no_pad, generate_d_tag, ListingDraftDocument, DRAFT_KIND}; + use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag}; use radroots_events_codec::d_tag::is_d_tag_base64url; #[test] diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,6 +1,8 @@ pub mod accounts; pub mod config; pub mod daemon; +#[allow(dead_code)] +pub mod farm_config; pub mod find; pub mod hyf; pub mod job;