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:
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;