cli

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

commit 5597fc481c64f1c72f5e77977e1c088768e7d88e
parent 9e4a5542e5e7a51b63a85d330f2077dd899b48e2
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 01:02:00 +0000

cli: add local farm commands

Diffstat:
Msrc/cli.rs | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Asrc/commands/farm.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/commands/mod.rs | 12+++++++++---
Msrc/domain/runtime.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/render/mod.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/runtime/farm.rs | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/farm_config.rs | 14+++++++++++++-
Msrc/runtime/mod.rs | 1+
8 files changed, 976 insertions(+), 15 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -56,6 +56,7 @@ pub enum Command { Account(AccountArgs), Config(ConfigArgs), Doctor, + Farm(FarmArgs), Find(FindArgs), Job(JobArgs), Listing(ListingArgs), @@ -83,6 +84,11 @@ impl Command { ConfigCommand::Show => "config show", }, Self::Doctor => "doctor", + Self::Farm(farm) => match farm.command { + FarmCommand::Setup(_) => "farm setup", + FarmCommand::Status(_) => "farm status", + FarmCommand::Get(_) => "farm get", + }, Self::Find(_) => "find", Self::Job(job) => match job.command { JobCommand::Ls => "job ls", @@ -179,6 +185,8 @@ impl Command { self, Self::Account(AccountArgs { command: AccountCommand::New | AccountCommand::Use(_), + }) | Self::Farm(FarmArgs { + command: FarmCommand::Setup(_), }) | Self::Local(LocalArgs { command: LocalCommand::Init | LocalCommand::Export(_) | LocalCommand::Backup(_), }) | Self::Sync(SyncArgs { @@ -256,6 +264,61 @@ pub enum RelayCommand { } #[derive(Debug, Clone, Args)] +pub struct FarmArgs { + #[command(subcommand)] + pub command: FarmCommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub enum FarmCommand { + Setup(FarmSetupArgs), + Status(FarmScopedArgs), + Get(FarmScopedArgs), +} + +#[derive(Debug, Clone, Args, Default)] +pub struct FarmScopedArgs { + #[arg(long, value_enum)] + pub scope: Option<FarmScopeArg>, +} + +#[derive(Debug, Clone, Args)] +pub struct FarmSetupArgs { + #[arg(long, value_enum)] + pub scope: Option<FarmScopeArg>, + #[arg(long = "farm-d-tag")] + pub farm_d_tag: Option<String>, + #[arg(long)] + pub name: String, + #[arg(long = "display-name")] + pub display_name: Option<String>, + #[arg(long)] + pub about: Option<String>, + #[arg(long)] + pub website: Option<String>, + #[arg(long)] + pub picture: Option<String>, + #[arg(long)] + pub banner: Option<String>, + #[arg(long)] + pub location: String, + #[arg(long)] + pub city: Option<String>, + #[arg(long)] + pub region: Option<String>, + #[arg(long)] + pub country: Option<String>, + #[arg(long = "delivery-method", default_value = "pickup")] + pub delivery_method: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum FarmScopeArg { + User, + Workspace, +} + +#[derive(Debug, Clone, Args)] pub struct NetArgs { #[command(subcommand)] pub command: NetCommand, @@ -511,10 +574,10 @@ pub struct RecordKeyArgs { #[cfg(test)] mod tests { use super::{ - AccountCommand, CliArgs, Command, ConfigCommand, JobCommand, JobWatchArgs, ListingCommand, - LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, - RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SignerCommand, SyncCommand, - SyncWatchArgs, + AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand, + JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, + OrderCommand, OrderWatchArgs, RelayCommand, RpcCommand, RuntimeCommand, + RuntimeConfigCommand, SignerCommand, SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; use clap::Parser; @@ -682,6 +745,57 @@ mod tests { _ => panic!("unexpected command variant"), } + let farm_setup = CliArgs::parse_from([ + "radroots", + "farm", + "setup", + "--scope", + "workspace", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--city", + "San Francisco", + "--region", + "CA", + "--country", + "US", + "--delivery-method", + "local_delivery", + ]); + match farm_setup.command { + Command::Farm(args) => match args.command { + FarmCommand::Setup(setup) => { + assert_eq!(setup.scope, Some(FarmScopeArg::Workspace)); + assert_eq!(setup.name, "La Huerta"); + assert_eq!(setup.location, "San Francisco, CA"); + assert_eq!(setup.city.as_deref(), Some("San Francisco")); + assert_eq!(setup.delivery_method, "local_delivery"); + } + _ => panic!("unexpected farm subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let farm_status = CliArgs::parse_from(["radroots", "farm", "status", "--scope", "user"]); + match farm_status.command { + Command::Farm(args) => match args.command { + FarmCommand::Status(status) => assert_eq!(status.scope, Some(FarmScopeArg::User)), + _ => panic!("unexpected farm subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let farm_get = CliArgs::parse_from(["radroots", "farm", "get"]); + match farm_get.command { + Command::Farm(args) => match args.command { + FarmCommand::Get(get) => assert!(get.scope.is_none()), + _ => panic!("unexpected farm subcommand"), + }, + _ => panic!("unexpected command variant"), + } + let relay = CliArgs::parse_from(["radroots", "relay", "ls"]); match relay.command { Command::Relay(args) => match args.command { @@ -1085,6 +1199,27 @@ mod tests { assert_eq!(account_new.command.display_name(), "account new"); assert!(!account_new.command.supports_dry_run()); + let farm_setup = CliArgs::parse_from([ + "radroots", + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + ]); + assert_eq!(farm_setup.command.display_name(), "farm setup"); + assert!(!farm_setup.command.supports_dry_run()); + + let farm_status = CliArgs::parse_from(["radroots", "farm", "status"]); + assert_eq!(farm_status.command.display_name(), "farm status"); + assert!(farm_status.command.supports_dry_run()); + assert!( + !farm_status + .command + .supports_output_format(OutputFormat::Ndjson) + ); + let find = CliArgs::parse_from(["radroots", "find", "eggs"]); assert!(find.command.supports_output_format(OutputFormat::Ndjson)); diff --git a/src/commands/farm.rs b/src/commands/farm.rs @@ -0,0 +1,72 @@ +use crate::cli::{FarmScopedArgs, FarmSetupArgs}; +use crate::domain::runtime::{ + CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmSetupView, FarmStatusView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::config::RuntimeConfig; + +pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::setup(config, args)?; + Ok(farm_setup_output(view)) +} + +pub fn status( + config: &RuntimeConfig, + args: &FarmScopedArgs, +) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::status(config, args)?; + Ok(farm_status_output(view)) +} + +pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::get(config, args)?; + Ok(farm_get_output(view)) +} + +fn farm_setup_output(view: FarmSetupView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::FarmSetup(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::FarmSetup(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::FarmSetup(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::FarmSetup(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::FarmSetup(view)) + } + } +} + +fn farm_status_output(view: FarmStatusView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::FarmStatus(view)), + CommandDisposition::Unconfigured => { + CommandOutput::unconfigured(CommandView::FarmStatus(view)) + } + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::FarmStatus(view)) + } + CommandDisposition::Unsupported => { + CommandOutput::unsupported(CommandView::FarmStatus(view)) + } + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::FarmStatus(view)) + } + } +} + +fn farm_get_output(view: FarmGetView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::FarmGet(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::FarmGet(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::FarmGet(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::FarmGet(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::FarmGet(view)) + } + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod doctor; +pub mod farm; pub mod find; pub mod identity; pub mod job; @@ -14,9 +15,9 @@ pub mod signer; pub mod sync; use crate::cli::{ - AccountCommand, Command, ConfigCommand, JobCommand, ListingCommand, LocalCommand, MycCommand, - NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, - SignerCommand, SyncCommand, + AccountCommand, Command, ConfigCommand, FarmCommand, JobCommand, ListingCommand, LocalCommand, + MycCommand, NetCommand, OrderCommand, RelayCommand, RpcCommand, RuntimeCommand, + RuntimeConfigCommand, SignerCommand, SyncCommand, }; use crate::domain::runtime::{CommandOutput, CommandView}; use crate::runtime::RuntimeError; @@ -51,6 +52,11 @@ pub fn dispatch( SignerCommand::Status => Ok(signer::status(config)), }, Command::Doctor => doctor::report(config, logging), + Command::Farm(farm_command) => match &farm_command.command { + FarmCommand::Setup(args) => farm::setup(config, args), + FarmCommand::Status(args) => farm::status(config, args), + FarmCommand::Get(args) => farm::get(config, args), + }, Command::Find(find_args) => find::search(config, find_args), Command::Job(job) => match &job.command { JobCommand::Ls => Ok(job::list(config)), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1,5 +1,8 @@ use std::process::ExitCode; +use radroots_events::farm::RadrootsFarm; +use radroots_events::listing::RadrootsListingLocation; +use radroots_events::profile::RadrootsProfile; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -83,6 +86,9 @@ pub enum CommandView { AccountWhoami(AccountWhoamiView), ConfigShow(ConfigShowView), Doctor(DoctorView), + FarmGet(FarmGetView), + FarmSetup(FarmSetupView), + FarmStatus(FarmStatusView), Find(FindView), JobGet(JobGetView), JobList(JobListView), @@ -632,6 +638,129 @@ pub struct LocalReplicaSyncView { } #[derive(Debug, Clone, Serialize)] +pub struct FarmSetupView { + pub state: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option<FarmConfigSummaryView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FarmSetupView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmStatusView { + pub state: String, + pub source: String, + pub scope: String, + pub path: String, + pub config_present: bool, + pub config_valid: bool, + pub account_state: String, + pub listing_defaults_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub config: Option<FarmConfigSummaryView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FarmStatusView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmGetView { + pub state: String, + pub source: String, + pub scope: String, + pub path: String, + pub config_present: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub document: Option<FarmConfigDocumentView>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl FarmGetView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmConfigSummaryView { + pub scope: String, + pub path: String, + pub selected_account_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_account_pubkey: Option<String>, + pub farm_d_tag: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_primary: Option<String>, + pub delivery_method: String, + pub publication: FarmPublicationView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmConfigDocumentView { + pub selection: FarmSelectionView, + pub profile: RadrootsProfile, + pub farm: RadrootsFarm, + pub listing_defaults: FarmListingDefaultsView, + pub publication: FarmPublicationView, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmSelectionView { + pub scope: String, + pub account: String, + pub farm_d_tag: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmListingDefaultsView { + pub delivery_method: String, + pub location: RadrootsListingLocation, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FarmPublicationView { + pub profile_state: String, + pub farm_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_published_at: Option<u64>, + #[serde(skip_serializing_if = "Option::is_none")] + pub farm_published_at: Option<u64>, +} + +#[derive(Debug, Clone, Serialize)] pub struct FindView { pub state: String, pub source: String, diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,13 +2,13 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, - FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingMutationView, - ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, - LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, - OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, - OrderWorkflowView, RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, - RuntimeLogsView, RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, - SyncWatchView, + FarmConfigSummaryView, FarmGetView, FarmSetupView, FarmStatusView, FindView, JobGetView, + JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView, + ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, + NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, + OrderJobView, OrderListView, OrderNewView, OrderSubmitView, OrderWatchView, OrderWorkflowView, + RelayListView, RpcSessionsView, RpcStatusView, RuntimeActionView, RuntimeLogsView, + RuntimeManagedConfigView, RuntimeStatusView, SyncActionView, SyncStatusView, SyncWatchView, }; use crate::runtime::RuntimeError; use crate::runtime::config::{OutputConfig, OutputFormat}; @@ -114,6 +114,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::Doctor(view) => { render_doctor(stdout, view)?; } + CommandView::FarmGet(view) => { + render_farm_get(stdout, view)?; + } + CommandView::FarmSetup(view) => { + render_farm_setup(stdout, view)?; + } + CommandView::FarmStatus(view) => { + render_farm_status(stdout, view)?; + } CommandView::Find(view) => { render_find(stdout, view)?; } @@ -290,6 +299,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::FarmGet(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::FarmSetup(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } + CommandView::FarmStatus(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::Find(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -2149,6 +2170,126 @@ fn render_sync_watch(stdout: &mut dyn Write, view: &SyncWatchView) -> Result<(), Ok(()) } +fn render_farm_setup(stdout: &mut dyn Write, view: &FarmSetupView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "configured" => "farm · configured", + "unconfigured" => "farm · unconfigured", + _ => "farm", + }, + )?; + if let Some(config) = &view.config { + render_farm_summary(stdout, config)?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_farm_status(stdout: &mut dyn Write, view: &FarmStatusView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "ready" => "farm · ready", + "unconfigured" => "farm · unconfigured", + _ => "farm", + }, + )?; + let rows = vec![ + ("scope", view.scope.clone()), + ("path", view.path.clone()), + ("config present", yes_no(view.config_present).to_owned()), + ("config valid", yes_no(view.config_valid).to_owned()), + ("account", view.account_state.clone()), + ("listing defaults", view.listing_defaults_state.clone()), + ]; + render_owned_pairs(stdout, "status", rows.as_slice())?; + if let Some(config) = &view.config { + render_farm_summary(stdout, config)?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_farm_get(stdout: &mut dyn Write, view: &FarmGetView) -> Result<(), RuntimeError> { + write_context( + stdout, + match view.state.as_str() { + "ready" => "farm · selected", + "unconfigured" => "farm · unconfigured", + _ => "farm", + }, + )?; + render_owned_pairs( + stdout, + "config", + &[ + ("scope", view.scope.clone()), + ("path", view.path.clone()), + ("config present", yes_no(view.config_present).to_owned()), + ], + )?; + if let Some(document) = &view.document { + let mut rows = vec![ + ("account id", document.selection.account.clone()), + ("farm d_tag", document.selection.farm_d_tag.clone()), + ("name", document.farm.name.clone()), + ( + "delivery method", + document.listing_defaults.delivery_method.clone(), + ), + ( + "profile publish", + document.publication.profile_state.clone(), + ), + ("farm publish", document.publication.farm_state.clone()), + ]; + if let Some(location) = &document.farm.location { + if let Some(primary) = &location.primary { + rows.push(("location", primary.clone())); + } + } + render_owned_pairs(stdout, "farm", rows.as_slice())?; + } + if let Some(reason) = &view.reason { + writeln!(stdout, "reason: {reason}")?; + } + writeln!(stdout, "source: {}", view.source)?; + render_actions(stdout, &view.actions)?; + Ok(()) +} + +fn render_farm_summary( + stdout: &mut dyn Write, + config: &FarmConfigSummaryView, +) -> Result<(), RuntimeError> { + let mut rows = vec![ + ("scope", config.scope.clone()), + ("path", config.path.clone()), + ("account id", config.selected_account_id.clone()), + ("farm d_tag", config.farm_d_tag.clone()), + ("name", config.name.clone()), + ("delivery method", config.delivery_method.clone()), + ("profile publish", config.publication.profile_state.clone()), + ("farm publish", config.publication.farm_state.clone()), + ]; + if let Some(pubkey) = &config.selected_account_pubkey { + rows.insert(3, ("account pubkey", pubkey.clone())); + } + if let Some(location) = &config.location_primary { + rows.push(("location", location.clone())); + } + render_owned_pairs(stdout, "farm", rows.as_slice()) +} + fn render_local_init(stdout: &mut dyn Write, view: &LocalInitView) -> Result<(), RuntimeError> { write_context(stdout, format!("local · {}", view.state).as_str())?; render_pairs( @@ -2559,6 +2700,9 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::AccountWhoami(_) => "account whoami", CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", + CommandView::FarmGet(_) => "farm get", + CommandView::FarmSetup(_) => "farm setup", + CommandView::FarmStatus(_) => "farm status", CommandView::Find(_) => "find", CommandView::JobGet(_) => "job get", CommandView::JobList(_) => "job ls", diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -0,0 +1,462 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use radroots_events::farm::{RadrootsFarm, RadrootsFarmLocation}; +use radroots_events::listing::RadrootsListingLocation; +use radroots_events::profile::RadrootsProfile; +use radroots_events_codec::d_tag::is_d_tag_base64url; + +use crate::cli::{FarmScopeArg, FarmScopedArgs, FarmSetupArgs}; +use crate::domain::runtime::{ + FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, + FarmPublicationView, FarmSelectionView, FarmSetupView, FarmStatusView, +}; +use crate::runtime::RuntimeError; +use crate::runtime::accounts::{self, AccountRecordView}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::farm_config::{ + self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, + FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, +}; + +const FARM_CONFIG_SOURCE: &str = "farm config · local first"; + +static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<FarmSetupView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let selected_account = match accounts::resolve_account(config)? { + Some(account) => account, + None => { + return Ok(FarmSetupView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + config: None, + reason: Some("farm setup requires a selected local account".to_owned()), + actions: vec![ + "radroots account new".to_owned(), + "radroots account whoami".to_owned(), + ], + }); + } + }; + let existing = farm_config::load(config, Some(resolved_scope))?; + let document = setup_document(args, resolved_scope, &selected_account, existing.as_ref())?; + let path = farm_config::write(&config.paths, resolved_scope, &document)?; + let summary = summary_view( + resolved_scope, + path.display().to_string(), + &document, + Some( + selected_account + .record + .public_identity + .public_key_hex + .as_str(), + ), + ); + + Ok(FarmSetupView { + state: "configured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + config: Some(summary), + reason: None, + actions: vec![ + "radroots farm status".to_owned(), + "radroots farm get".to_owned(), + ], + }) +} + +pub fn status( + config: &RuntimeConfig, + args: &FarmScopedArgs, +) -> Result<FarmStatusView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let path = farm_config::config_path(&config.paths, resolved_scope)?; + let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(FarmStatusView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved_scope.as_str().to_owned(), + path: path.display().to_string(), + config_present: false, + config_valid: false, + account_state: "not_checked".to_owned(), + listing_defaults_state: "missing".to_owned(), + config: None, + reason: Some(format!("no farm config found at {}", path.display())), + actions: vec![setup_action(resolved_scope)], + }); + }; + + let account = configured_account(config, &resolved.document.selection.account)?; + let account_state = if account.is_some() { + "ready" + } else { + "missing" + }; + let state = if account.is_some() { + "ready" + } else { + "unconfigured" + }; + let reason = if account.is_some() { + None + } else { + Some(format!( + "farm config account `{}` is not present in the local account store", + resolved.document.selection.account + )) + }; + let mut actions = Vec::new(); + if account.is_none() { + actions.push("radroots account new".to_owned()); + actions.push(setup_action(resolved.scope)); + } + let account_pubkey = account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.as_str()); + + Ok(FarmStatusView { + state: state.to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved.scope.as_str().to_owned(), + path: resolved.path.display().to_string(), + config_present: true, + config_valid: true, + account_state: account_state.to_owned(), + listing_defaults_state: "ready".to_owned(), + config: Some(summary_view( + resolved.scope, + resolved.path.display().to_string(), + &resolved.document, + account_pubkey, + )), + reason, + actions, + }) +} + +pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let path = farm_config::config_path(&config.paths, resolved_scope)?; + let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(FarmGetView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved_scope.as_str().to_owned(), + path: path.display().to_string(), + config_present: false, + document: None, + reason: Some(format!("no farm config found at {}", path.display())), + actions: vec![setup_action(resolved_scope)], + }); + }; + + Ok(FarmGetView { + state: "ready".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + scope: resolved.scope.as_str().to_owned(), + path: resolved.path.display().to_string(), + config_present: true, + document: Some(document_view(&resolved.document)), + reason: None, + actions: Vec::new(), + }) +} + +fn setup_document( + args: &FarmSetupArgs, + scope: FarmConfigScope, + account: &AccountRecordView, + existing: Option<&ResolvedFarmConfig>, +) -> Result<FarmConfigDocument, RuntimeError> { + let existing_document = existing.map(|resolved| &resolved.document); + let name = required_text(args.name.as_str(), "farm.name")?; + let location_primary = required_text(args.location.as_str(), "farm.location.primary")?; + let delivery_method = required_text( + args.delivery_method.as_str(), + "listing_defaults.delivery_method", + )?; + let farm_d_tag = match args.farm_d_tag.as_deref() { + Some(value) => required_d_tag(value, "farm_d_tag")?, + None => existing_document + .map(|document| document.farm.d_tag.clone()) + .unwrap_or_else(generate_d_tag), + }; + if !is_d_tag_base64url(farm_d_tag.as_str()) { + return Err(RuntimeError::Config( + "farm_d_tag must be a 22-character base64url identifier".to_owned(), + )); + } + + let about = optional_arg_or_existing( + args.about.as_ref(), + existing_document.and_then(|document| document.profile.about.as_ref()), + ); + let website = optional_arg_or_existing( + args.website.as_ref(), + existing_document.and_then(|document| document.profile.website.as_ref()), + ); + let picture = optional_arg_or_existing( + args.picture.as_ref(), + existing_document.and_then(|document| document.profile.picture.as_ref()), + ); + let banner = optional_arg_or_existing( + args.banner.as_ref(), + existing_document.and_then(|document| document.profile.banner.as_ref()), + ); + let display_name = optional_arg_or_existing( + args.display_name.as_ref(), + existing_document.and_then(|document| document.profile.display_name.as_ref()), + ) + .or_else(|| Some(name.clone())); + let city = optional_arg_or_existing( + args.city.as_ref(), + existing_document + .and_then(|document| document.farm.location.as_ref()) + .and_then(|location| location.city.as_ref()), + ); + let region = optional_arg_or_existing( + args.region.as_ref(), + existing_document + .and_then(|document| document.farm.location.as_ref()) + .and_then(|location| location.region.as_ref()), + ); + let country = optional_arg_or_existing( + args.country.as_ref(), + existing_document + .and_then(|document| document.farm.location.as_ref()) + .and_then(|location| location.country.as_ref()), + ); + let publication = existing_document + .filter(|document| document.farm.d_tag == farm_d_tag) + .map(|document| document.publication.clone()) + .unwrap_or_default(); + + Ok(FarmConfigDocument { + version: SUPPORTED_FARM_CONFIG_VERSION, + selection: FarmConfigSelection { + scope, + account: account.record.account_id.to_string(), + farm_d_tag: farm_d_tag.clone(), + }, + profile: RadrootsProfile { + name: name.clone(), + display_name, + nip05: None, + about: about.clone(), + website: website.clone(), + picture: picture.clone(), + banner: banner.clone(), + lud06: None, + lud16: None, + bot: None, + }, + farm: RadrootsFarm { + d_tag: farm_d_tag, + name, + about, + website, + picture, + banner, + location: Some(RadrootsFarmLocation { + primary: Some(location_primary.clone()), + city: city.clone(), + region: region.clone(), + country: country.clone(), + gcs: None, + }), + tags: None, + }, + listing_defaults: FarmListingDefaults { + delivery_method, + location: RadrootsListingLocation { + primary: location_primary, + city, + region, + country, + lat: None, + lng: None, + geohash: None, + }, + }, + publication, + }) +} + +fn configured_account( + config: &RuntimeConfig, + account_id: &str, +) -> Result<Option<AccountRecordView>, RuntimeError> { + let snapshot = accounts::snapshot(config)?; + Ok(snapshot + .accounts + .into_iter() + .find(|account| account.record.account_id.as_str() == account_id)) +} + +fn summary_view( + scope: FarmConfigScope, + path: String, + document: &FarmConfigDocument, + account_pubkey: Option<&str>, +) -> FarmConfigSummaryView { + FarmConfigSummaryView { + scope: scope.as_str().to_owned(), + path, + selected_account_id: document.selection.account.clone(), + selected_account_pubkey: account_pubkey.map(str::to_owned), + farm_d_tag: document.selection.farm_d_tag.clone(), + name: document.farm.name.clone(), + location_primary: document + .farm + .location + .as_ref() + .and_then(|location| location.primary.clone()), + delivery_method: document.listing_defaults.delivery_method.clone(), + publication: publication_view(&document.publication), + } +} + +fn document_view(document: &FarmConfigDocument) -> FarmConfigDocumentView { + FarmConfigDocumentView { + selection: FarmSelectionView { + scope: document.selection.scope.as_str().to_owned(), + account: document.selection.account.clone(), + farm_d_tag: document.selection.farm_d_tag.clone(), + }, + profile: document.profile.clone(), + farm: document.farm.clone(), + listing_defaults: FarmListingDefaultsView { + delivery_method: document.listing_defaults.delivery_method.clone(), + location: document.listing_defaults.location.clone(), + }, + publication: publication_view(&document.publication), + } +} + +fn publication_view(publication: &FarmPublicationStatus) -> FarmPublicationView { + FarmPublicationView { + profile_state: publish_state( + publication.profile_event_id.as_deref(), + publication.profile_published_at, + ) + .to_owned(), + farm_state: publish_state( + publication.farm_event_id.as_deref(), + publication.farm_published_at, + ) + .to_owned(), + profile_event_id: publication.profile_event_id.clone(), + farm_event_id: publication.farm_event_id.clone(), + profile_published_at: publication.profile_published_at, + farm_published_at: publication.farm_published_at, + } +} + +fn publish_state(event_id: Option<&str>, published_at: Option<u64>) -> &'static str { + if event_id.is_some_and(|value| !value.trim().is_empty()) || published_at.is_some() { + "published" + } else { + "not_published" + } +} + +fn setup_action(scope: FarmConfigScope) -> String { + format!( + "radroots farm setup --scope {} --name <farm-name> --location <place>", + scope.as_str() + ) +} + +fn scope_from_arg(scope: Option<FarmScopeArg>) -> Option<FarmConfigScope> { + scope.map(|scope| match scope { + FarmScopeArg::User => FarmConfigScope::User, + FarmScopeArg::Workspace => FarmConfigScope::Workspace, + }) +} + +fn required_d_tag(value: &str, field: &str) -> Result<String, RuntimeError> { + let value = required_text(value, field)?; + if !is_d_tag_base64url(value.as_str()) { + return Err(RuntimeError::Config(format!( + "{field} must be a 22-character base64url identifier" + ))); + } + Ok(value) +} + +fn required_text(value: &str, field: &str) -> Result<String, RuntimeError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(RuntimeError::Config(format!("{field} must not be empty"))); + } + Ok(trimmed.to_owned()) +} + +fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> Option<String> { + arg.and_then(|value| non_empty(value.as_str())) + .or_else(|| existing.and_then(|value| non_empty(value.as_str()))) +} + +fn non_empty(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +fn generate_d_tag() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) +} + +fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = String::with_capacity(22); + let mut index = 0usize; + while index + 3 <= bytes.len() { + let block = ((bytes[index] as u32) << 16) + | ((bytes[index + 1] as u32) << 8) + | (bytes[index + 2] as u32); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); + output.push(ALPHABET[(block & 0x3f) as usize] as char); + index += 3; + } + let remaining = bytes.len() - index; + if remaining == 1 { + let block = (bytes[index] as u32) << 16; + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + } else if remaining == 2 { + let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::generate_d_tag; + use radroots_events_codec::d_tag::is_d_tag_base64url; + + #[test] + fn generated_farm_d_tag_is_valid_base64url() { + assert!(is_d_tag_base64url(&generate_d_tag())); + } +} diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs @@ -4,13 +4,14 @@ use std::path::{Path, PathBuf}; use radroots_events::farm::RadrootsFarm; use radroots_events::listing::{RadrootsListingDeliveryMethod, RadrootsListingLocation}; use radroots_events::profile::RadrootsProfile; +use radroots_events_codec::d_tag::is_d_tag_base64url; 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; +pub const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -207,6 +208,12 @@ pub fn validate( "farm config selection.farm_d_tag must not be empty".to_owned(), )); } + if !is_d_tag_base64url(trimmed(document.selection.farm_d_tag.as_str())) { + return Err(RuntimeError::Config( + "farm config selection.farm_d_tag must be a 22-character base64url identifier" + .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(), @@ -217,6 +224,11 @@ pub fn validate( "farm config farm.d_tag must not be empty".to_owned(), )); } + if !is_d_tag_base64url(trimmed(document.farm.d_tag.as_str())) { + return Err(RuntimeError::Config( + "farm config farm.d_tag must be a 22-character base64url identifier".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(), diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,6 +1,7 @@ pub mod accounts; pub mod config; pub mod daemon; +pub mod farm; #[allow(dead_code)] pub mod farm_config; pub mod find;