cli

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

commit b97c057872fdbdf6be3599d0396f684ea1e35a49
parent 26deb5b552da91da235c5dc42971c7e4295e993a
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 21:01:05 +0000

implement iterative farm drafting

Diffstat:
Msrc/cli.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/commands/farm.rs | 30+++++++++++++++++++++++++++---
Msrc/commands/mod.rs | 2++
Msrc/domain/runtime.rs | 30+++++++++++++++++++++++++++++-
Msrc/render/mod.rs | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/runtime/farm.rs | 626++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/runtime/farm_config.rs | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/runtime/listing.rs | 51+++++++++++++++++++++++++++++++++++----------------
Atests/farm.rs | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/help.rs | 6+++++-
Mtests/workflow.rs | 2+-
11 files changed, 1420 insertions(+), 214 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -109,11 +109,13 @@ Compatibility aliases: new, whoami, ls, use. const FARM_HELP: &str = "\ Examples: + radroots farm init + radroots farm set delivery pickup radroots farm check radroots farm show --scope workspace radroots farm publish -Compatibility aliases: status, get. The all-at-once `farm setup` surface remains available. +Compatibility paths: `farm setup`, `farm status`, and `farm get` remain available. "; const MARKET_HELP: &str = "\ @@ -438,6 +440,8 @@ impl Command { }, Self::Doctor => "doctor", Self::Farm(farm) => match farm.command { + FarmCommand::Init(_) => "farm init", + FarmCommand::Set(_) => "farm set", FarmCommand::Publish(_) => "farm publish", FarmCommand::Setup(_) => "farm setup", FarmCommand::Status(_) => "farm check", @@ -564,7 +568,7 @@ impl Command { Self::Account(AccountArgs { command: AccountCommand::New | AccountCommand::Use(_), }) | Self::Farm(FarmArgs { - command: FarmCommand::Setup(_), + command: FarmCommand::Init(_) | FarmCommand::Set(_) | FarmCommand::Setup(_), }) | Self::Local(LocalArgs { command: LocalCommand::Init | LocalCommand::Export(_) | LocalCommand::Backup(_), }) | Self::Sync(SyncArgs { @@ -683,6 +687,10 @@ pub struct FarmArgs { #[derive(Debug, Clone, Subcommand)] pub enum FarmCommand { + #[command(about = "Create or refresh a farm draft progressively")] + Init(FarmInitArgs), + #[command(about = "Set one farm draft field")] + Set(FarmSetArgs), #[command(about = "Publish the current farm draft")] Publish(FarmPublishArgs), #[command(about = "Create or update a farm draft in one command")] @@ -717,6 +725,62 @@ pub struct FarmScopedArgs { pub scope: Option<FarmScopeArg>, } +#[derive(Debug, Clone, Args, Default)] +pub struct FarmInitArgs { + #[arg(long, value_enum)] + pub scope: Option<FarmScopeArg>, + #[arg(long = "farm-d-tag")] + pub farm_d_tag: Option<String>, + #[arg(long)] + pub name: Option<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: Option<String>, + #[arg(long)] + pub city: Option<String>, + #[arg(long)] + pub region: Option<String>, + #[arg(long)] + pub country: Option<String>, + #[arg(long = "delivery", visible_alias = "delivery-method")] + pub delivery_method: Option<String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum FarmFieldArg { + Name, + #[value(name = "display_name", alias = "display-name")] + DisplayName, + About, + Website, + Picture, + Banner, + Location, + City, + Region, + Country, + Delivery, +} + +#[derive(Debug, Clone, Args)] +pub struct FarmSetArgs { + #[arg(long, value_enum)] + pub scope: Option<FarmScopeArg>, + #[arg(value_enum)] + pub field: FarmFieldArg, + #[arg(value_name = "value", num_args = 1..)] + pub value: Vec<String>, +} + #[derive(Debug, Clone, Args)] pub struct FarmSetupArgs { #[arg(long, value_enum)] @@ -1108,11 +1172,11 @@ pub struct SellRestockArgs { #[cfg(test)] mod tests { use super::{ - AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand, - JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MarketCommand, - MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, RelayCommand, - RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, SignerCommand, - SyncCommand, SyncWatchArgs, + AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmFieldArg, FarmScopeArg, + JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, + MarketCommand, MycCommand, NetCommand, OrderCommand, OrderWatchArgs, OutputFormatArg, + RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SellCommand, SetupRoleArg, + SignerCommand, SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; #[test] @@ -1504,6 +1568,55 @@ mod tests { _ => panic!("unexpected command variant"), } + let farm_init = CliArgs::parse_from([ + "radroots", + "farm", + "init", + "--scope", + "workspace", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--delivery", + "pickup", + ]); + match farm_init.command { + Command::Farm(args) => match args.command { + FarmCommand::Init(init) => { + assert_eq!(init.scope, Some(FarmScopeArg::Workspace)); + assert_eq!(init.name.as_deref(), Some("La Huerta")); + assert_eq!(init.location.as_deref(), Some("San Francisco, CA")); + assert_eq!(init.delivery_method.as_deref(), Some("pickup")); + } + _ => panic!("unexpected farm subcommand"), + }, + _ => panic!("unexpected command variant"), + } + + let farm_set = CliArgs::parse_from([ + "radroots", + "farm", + "set", + "--scope", + "user", + "display-name", + "La", + "Huerta", + "Farm", + ]); + match farm_set.command { + Command::Farm(args) => match args.command { + FarmCommand::Set(set) => { + assert_eq!(set.scope, Some(FarmScopeArg::User)); + assert_eq!(set.field, FarmFieldArg::DisplayName); + assert_eq!(set.value, vec!["La", "Huerta", "Farm"]); + } + _ => 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 { @@ -1990,6 +2103,14 @@ mod tests { assert_eq!(account_create.command.display_name(), "account create"); assert!(!account_create.command.supports_dry_run()); + let farm_init = CliArgs::parse_from(["radroots", "farm", "init"]); + assert_eq!(farm_init.command.display_name(), "farm init"); + assert!(!farm_init.command.supports_dry_run()); + + let farm_set = CliArgs::parse_from(["radroots", "farm", "set", "name", "La Huerta"]); + assert_eq!(farm_set.command.display_name(), "farm set"); + assert!(!farm_set.command.supports_dry_run()); + let farm_setup = CliArgs::parse_from([ "radroots", "farm", diff --git a/src/commands/farm.rs b/src/commands/farm.rs @@ -1,7 +1,7 @@ -use crate::cli::{FarmPublishArgs, FarmScopedArgs, FarmSetupArgs}; +use crate::cli::{FarmInitArgs, FarmPublishArgs, FarmScopedArgs, FarmSetArgs, FarmSetupArgs}; use crate::domain::runtime::{ - CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmPublishView, FarmSetupView, - FarmStatusView, + CommandDisposition, CommandOutput, CommandView, FarmGetView, FarmPublishView, FarmSetView, + FarmSetupView, FarmStatusView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -11,6 +11,16 @@ pub fn setup(config: &RuntimeConfig, args: &FarmSetupArgs) -> Result<CommandOutp Ok(farm_setup_output(view)) } +pub fn init(config: &RuntimeConfig, args: &FarmInitArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::init(config, args)?; + Ok(farm_setup_output(view)) +} + +pub fn set(config: &RuntimeConfig, args: &FarmSetArgs) -> Result<CommandOutput, RuntimeError> { + let view = crate::runtime::farm::set(config, args)?; + Ok(farm_set_output(view)) +} + pub fn publish( config: &RuntimeConfig, args: &FarmPublishArgs, @@ -66,6 +76,20 @@ fn farm_setup_output(view: FarmSetupView) -> CommandOutput { } } +fn farm_set_output(view: FarmSetView) -> CommandOutput { + match view.disposition() { + CommandDisposition::Success => CommandOutput::success(CommandView::FarmSet(view)), + CommandDisposition::Unconfigured => CommandOutput::unconfigured(CommandView::FarmSet(view)), + CommandDisposition::ExternalUnavailable => { + CommandOutput::external_unavailable(CommandView::FarmSet(view)) + } + CommandDisposition::Unsupported => CommandOutput::unsupported(CommandView::FarmSet(view)), + CommandDisposition::InternalError => { + CommandOutput::internal_error(CommandView::FarmSet(view)) + } + } +} + fn farm_status_output(view: FarmStatusView) -> CommandOutput { match view.disposition() { CommandDisposition::Success => CommandOutput::success(CommandView::FarmStatus(view)), diff --git a/src/commands/mod.rs b/src/commands/mod.rs @@ -54,6 +54,8 @@ pub fn dispatch( }, Command::Doctor => doctor::report(config, logging), Command::Farm(farm_command) => match &farm_command.command { + FarmCommand::Init(args) => farm::init(config, args), + FarmCommand::Set(args) => farm::set(config, args), FarmCommand::Publish(args) => farm::publish(config, args), FarmCommand::Setup(args) => farm::setup(config, args), FarmCommand::Status(args) => farm::status(config, args), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -88,6 +88,7 @@ pub enum CommandView { Doctor(DoctorView), FarmGet(FarmGetView), FarmPublish(FarmPublishView), + FarmSet(FarmSetView), FarmSetup(FarmSetupView), FarmStatus(FarmStatusView), Find(FindView), @@ -729,6 +730,29 @@ impl FarmSetupView { } #[derive(Debug, Clone, Serialize)] +pub struct FarmSetView { + pub state: String, + pub source: String, + pub field: String, + pub value: 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 FarmSetView { + 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, @@ -740,6 +764,8 @@ pub struct FarmStatusView { pub listing_defaults_state: String, #[serde(skip_serializing_if = "Option::is_none")] pub config: Option<FarmConfigSummaryView>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -773,7 +799,7 @@ pub struct FarmGetView { impl FarmGetView { pub fn disposition(&self) -> CommandDisposition { match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, + "unconfigured" | "missing" => CommandDisposition::Unconfigured, _ => CommandDisposition::Success, } } @@ -794,6 +820,8 @@ pub struct FarmPublishView { pub requested_signer_session_id: Option<String>, pub profile: FarmPublishComponentView, pub farm: FarmPublishComponentView, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -2,8 +2,8 @@ use std::io::{self, Write}; use crate::domain::runtime::{ AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView, - FarmConfigSummaryView, FarmGetView, FarmPublishComponentView, FarmPublishView, FarmSetupView, - FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView, + FarmConfigSummaryView, FarmGetView, FarmPublishComponentView, FarmPublishView, FarmSetView, + FarmSetupView, FarmStatusView, FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingMutationView, ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView, LocalStatusView, NetStatusView, OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderJobView, OrderListView, OrderNewView, OrderSubmitView, @@ -121,6 +121,9 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), CommandView::FarmPublish(view) => { render_farm_publish(stdout, view)?; } + CommandView::FarmSet(view) => { + render_farm_set(stdout, view)?; + } CommandView::FarmSetup(view) => { render_farm_setup(stdout, view)?; } @@ -317,6 +320,10 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; } + CommandView::FarmSet(view) => { + serde_json::to_writer_pretty(&mut *stdout, view)?; + writeln!(stdout)?; + } CommandView::FarmSetup(view) => { serde_json::to_writer_pretty(&mut *stdout, view)?; writeln!(stdout)?; @@ -2217,103 +2224,121 @@ fn render_sync_watch(stdout: &mut dyn Write, view: &SyncWatchView) -> Result<(), } 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)?; + match view.state.as_str() { + "unconfigured" => { + writeln!(stdout, "Not ready yet")?; + writeln!(stdout)?; + render_item_section(stdout, "Missing", &["Selected account".to_owned()])?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } + _ => { + writeln!(stdout, "Farm draft saved")?; + if let Some(reason) = &view.reason { + writeln!(stdout)?; + writeln!(stdout, "{reason}")?; + } + if let Some(config) = &view.config { + writeln!(stdout)?; + render_farm_summary(stdout, config)?; + } + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } } - if let Some(reason) = &view.reason { - writeln!(stdout, "reason: {reason}")?; +} + +fn render_farm_set(stdout: &mut dyn Write, view: &FarmSetView) -> Result<(), RuntimeError> { + match view.state.as_str() { + "unconfigured" => { + writeln!(stdout, "Farm draft not found")?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } + _ => { + writeln!(stdout, "Farm updated")?; + writeln!(stdout)?; + render_owned_pairs( + stdout, + "Changed", + &[("Field", view.field.clone()), ("Value", view.value.clone())], + )?; + if let Some(config) = &view.config { + render_farm_summary(stdout, config)?; + } + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } } - 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}")?; + match view.state.as_str() { + "ready" => { + writeln!(stdout, "Farm ready to publish")?; + if let Some(config) = &view.config { + writeln!(stdout)?; + render_farm_summary(stdout, config)?; + } + if !view.actions.is_empty() { + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } + _ => { + writeln!(stdout, "Farm not ready yet")?; + if !view.missing.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Missing", &view.missing)?; + } + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + Ok(()) + } } - 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())); - } + writeln!(stdout, "Farm draft")?; + writeln!(stdout)?; + render_farm_document(stdout, document)?; + } else { + writeln!(stdout, "Farm draft not found")?; + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; } - 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_publish(stdout: &mut dyn Write, view: &FarmPublishView) -> Result<(), RuntimeError> { + if view.state == "unconfigured" { + writeln!(stdout, "Not ready yet")?; + if !view.missing.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Missing", &view.missing)?; + } + if !view.actions.is_empty() { + writeln!(stdout)?; + render_item_section(stdout, "Next", &view.actions)?; + } + return Ok(()); + } + write_context(stdout, format!("farm publish · {}", view.state).as_str())?; render_owned_pairs( stdout, @@ -2337,6 +2362,99 @@ fn render_farm_publish(stdout: &mut dyn Write, view: &FarmPublishView) -> Result Ok(()) } +fn render_farm_document( + stdout: &mut dyn Write, + document: &crate::domain::runtime::FarmConfigDocumentView, +) -> Result<(), RuntimeError> { + let mut rows = Vec::new(); + push_row( + &mut rows, + "Name", + first_present([ + Some(document.profile.name.as_str()), + Some(document.farm.name.as_str()), + ]), + ); + push_row( + &mut rows, + "Display name", + document.profile.display_name.as_deref().map(str::to_owned), + ); + push_row( + &mut rows, + "About", + first_present([ + document.profile.about.as_deref(), + document.farm.about.as_deref(), + ]), + ); + push_row( + &mut rows, + "Website", + first_present([ + document.profile.website.as_deref(), + document.farm.website.as_deref(), + ]), + ); + push_row( + &mut rows, + "Place", + first_present([ + Some(document.listing_defaults.location.primary.as_str()), + document + .farm + .location + .as_ref() + .and_then(|location| location.primary.as_deref()), + ]), + ); + push_row( + &mut rows, + "City", + first_present([ + document.listing_defaults.location.city.as_deref(), + document + .farm + .location + .as_ref() + .and_then(|location| location.city.as_deref()), + ]), + ); + push_row( + &mut rows, + "Region", + first_present([ + document.listing_defaults.location.region.as_deref(), + document + .farm + .location + .as_ref() + .and_then(|location| location.region.as_deref()), + ]), + ); + push_row( + &mut rows, + "Country", + first_present([ + document.listing_defaults.location.country.as_deref(), + document + .farm + .location + .as_ref() + .and_then(|location| location.country.as_deref()), + ]), + ); + push_row( + &mut rows, + "Delivery", + non_empty_str(document.listing_defaults.delivery_method.as_str()) + .map(humanize_delivery_method), + ); + rows.push(("Scope", document.selection.scope.clone())); + rows.push(("Farm tag", document.selection.farm_d_tag.clone())); + render_owned_pairs(stdout, "Farm", rows.as_slice()) +} + fn render_farm_publish_component( stdout: &mut dyn Write, label: &str, @@ -2369,23 +2487,28 @@ 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()) + let mut rows = Vec::new(); + push_row( + &mut rows, + "Name", + non_empty_str(config.name.as_str()).map(str::to_owned), + ); + rows.push(("Scope", config.scope.clone())); + push_row( + &mut rows, + "Place", + config + .location_primary + .as_deref() + .and_then(non_empty_str) + .map(str::to_owned), + ); + push_row( + &mut rows, + "Delivery", + non_empty_str(config.delivery_method.as_str()).map(humanize_delivery_method), + ); + render_owned_pairs(stdout, "Farm", rows.as_slice()) } fn render_local_init(stdout: &mut dyn Write, view: &LocalInitView) -> Result<(), RuntimeError> { @@ -2535,6 +2658,48 @@ fn render_item_section( Ok(()) } +fn push_row(rows: &mut Vec<(&'static str, String)>, label: &'static str, value: Option<String>) { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + rows.push((label, value)); + } +} + +fn first_present<const N: usize>(values: [Option<&str>; N]) -> Option<String> { + values + .into_iter() + .flatten() + .find_map(|value| non_empty_str(value).map(str::to_owned)) +} + +fn non_empty_str(value: &str) -> Option<&str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn humanize_delivery_method(value: &str) -> String { + value + .split('_') + .filter(|segment| !segment.is_empty()) + .map(capitalize_ascii_word) + .collect::<Vec<_>>() + .join(" ") +} + +fn capitalize_ascii_word(word: &str) -> String { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut rendered = String::new(); + rendered.push(first.to_ascii_uppercase()); + rendered.push_str(chars.as_str()); + rendered +} + fn render_local_backup(stdout: &mut dyn Write, view: &LocalBackupView) -> Result<(), RuntimeError> { write_context(stdout, format!("local · {}", view.state).as_str())?; let size_bytes = view.size_bytes.to_string(); @@ -2873,10 +3038,17 @@ fn human_command_name(view: &CommandView) -> &'static str { CommandView::AccountWhoami(_) => "account whoami", CommandView::ConfigShow(_) => "config show", CommandView::Doctor(_) => "doctor", - CommandView::FarmGet(_) => "farm get", + CommandView::FarmGet(_) => "farm show", CommandView::FarmPublish(_) => "farm publish", - CommandView::FarmSetup(_) => "farm setup", - CommandView::FarmStatus(_) => "farm status", + CommandView::FarmSet(_) => "farm set", + CommandView::FarmSetup(view) => { + if view.state == "saved" { + "farm init" + } else { + "farm setup" + } + } + CommandView::FarmStatus(_) => "farm check", CommandView::Find(_) => "find", CommandView::JobGet(_) => "job get", CommandView::JobList(_) => "job ls", diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -9,11 +9,14 @@ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::farm::encode::to_wire_parts_with_kind; use radroots_events_codec::profile::encode::to_wire_parts_with_profile_type; -use crate::cli::{FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmSetupArgs}; +use crate::cli::{ + FarmFieldArg, FarmInitArgs, FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmSetArgs, + FarmSetupArgs, +}; use crate::domain::runtime::{ FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, FarmPublicationView, FarmPublishComponentView, FarmPublishEventView, FarmPublishJobView, - FarmPublishView, FarmSelectionView, FarmSetupView, FarmStatusView, + FarmPublishView, FarmSelectionView, FarmSetView, FarmSetupView, FarmStatusView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{self, AccountRecordView}; @@ -21,7 +24,7 @@ use crate::runtime::config::RuntimeConfig; use crate::runtime::daemon::{self, BridgeEventPublishResult, DaemonRpcError}; use crate::runtime::farm_config::{ self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, - FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, + FarmMissingField, FarmPublicationStatus, ResolvedFarmConfig, SUPPORTED_FARM_CONFIG_VERSION, }; use crate::runtime::signer::{ActorWriteBindingError, resolve_actor_write_authority}; @@ -29,49 +32,82 @@ const FARM_CONFIG_SOURCE: &str = "farm config · local first"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); +pub fn init(config: &RuntimeConfig, args: &FarmInitArgs) -> Result<FarmSetupView, RuntimeError> { + let scope = scope_from_arg(args.scope); + let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; + let Some(selected_account) = selected_account_for_draft(config)? else { + return Ok(missing_selected_account_setup_view()); + }; + let existing = farm_config::load(config, Some(resolved_scope))?; + let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; + save_draft_view( + "saved", + resolved_scope, + &selected_account, + &document, + Some("The farm draft is local until you publish it.".to_owned()), + farm_setup_actions(&document), + config, + ) +} + 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 Some(selected_account) = selected_account_for_draft(config)? else { + return Ok(missing_selected_account_setup_view()); }; 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( + save_draft_view( + "configured", resolved_scope, - path.display().to_string(), + &selected_account, &document, - Some( - selected_account - .record - .public_identity - .public_key_hex - .as_str(), - ), - ); + None, + farm_setup_actions(&document), + config, + ) +} - Ok(FarmSetupView { - state: "configured".to_owned(), +pub fn set(config: &RuntimeConfig, args: &FarmSetArgs) -> Result<FarmSetView, 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(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { + return Ok(FarmSetView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + field: human_field_name(args.field).to_owned(), + value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(), + config: None, + reason: Some(format!("no farm draft found at {}", path.display())), + actions: vec!["radroots farm init".to_owned()], + }); + }; + + let raw_value = args.value.join(" "); + let field_value = required_text(raw_value.as_str(), "farm set value")?; + apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; + let written_path = farm_config::write(&config.paths, resolved.scope, &resolved.document)?; + let configured_account = configured_account(config, &resolved.document.selection.account)?; + let account_pubkey = configured_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.as_str()); + + Ok(FarmSetView { + state: "updated".to_owned(), source: FARM_CONFIG_SOURCE.to_owned(), - config: Some(summary), + field: human_field_name(args.field).to_owned(), + value: human_field_value(args.field, field_value.as_str()).to_owned(), + config: Some(summary_view( + resolved.scope, + written_path.display().to_string(), + &resolved.document, + account_pubkey, + )), reason: None, - actions: vec![ - "radroots farm status".to_owned(), - "radroots farm get".to_owned(), - ], + actions: vec!["radroots farm check".to_owned()], }) } @@ -93,34 +129,46 @@ pub fn status( account_state: "not_checked".to_owned(), listing_defaults_state: "missing".to_owned(), config: None, + missing: vec!["Farm draft".to_owned()], reason: Some(format!("no farm config found at {}", path.display())), - actions: vec![setup_action(resolved_scope)], + actions: vec!["radroots farm init".to_owned()], }); }; let account = configured_account(config, &resolved.document.selection.account)?; + let draft_missing = farm_config::missing_fields(&resolved.document); let account_state = if account.is_some() { "ready" } else { "missing" }; - let state = if account.is_some() { + let listing_defaults_state = if missing_blocks_listing_defaults(draft_missing.as_slice()) { + "missing" + } else { + "ready" + }; + let state = if account.is_some() && draft_missing.is_empty() { "ready" } else { "unconfigured" }; - let reason = if account.is_some() { - None - } else { + let reason = if account.is_none() { Some(format!( "farm config account `{}` is not present in the local account store", resolved.document.selection.account )) + } else if !draft_missing.is_empty() { + Some("farm draft is missing required fields".to_owned()) + } else { + None }; let mut actions = Vec::new(); if account.is_none() { - actions.push("radroots account new".to_owned()); - actions.push(setup_action(resolved.scope)); + actions.push("radroots account create".to_owned()); + } else if draft_missing.is_empty() { + actions.push("radroots farm publish".to_owned()); + } else { + actions.extend(missing_field_actions(draft_missing.as_slice())); } let account_pubkey = account .as_ref() @@ -134,13 +182,18 @@ pub fn status( config_present: true, config_valid: true, account_state: account_state.to_owned(), - listing_defaults_state: "ready".to_owned(), + listing_defaults_state: listing_defaults_state.to_owned(), config: Some(summary_view( resolved.scope, resolved.path.display().to_string(), &resolved.document, account_pubkey, )), + missing: if account.is_none() { + vec!["Selected account".to_owned()] + } else { + missing_field_labels(draft_missing.as_slice()) + }, reason, actions, }) @@ -159,7 +212,7 @@ pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, config_present: false, document: None, reason: Some(format!("no farm config found at {}", path.display())), - actions: vec![setup_action(resolved_scope)], + actions: vec!["radroots farm init".to_owned()], }); }; @@ -188,6 +241,12 @@ pub fn publish( path.display().to_string(), args, format!("no farm config found at {}", path.display()), + vec!["Farm draft".to_owned()], + vec!["radroots farm init".to_owned()], + false, + String::new(), + String::new(), + String::new(), )); }; @@ -200,8 +259,29 @@ pub fn publish( "farm config account `{}` is not present in the local account store", resolved.document.selection.account ), + vec!["Selected account".to_owned()], + vec!["radroots account create".to_owned()], + true, + resolved.document.selection.account.clone(), + String::new(), + resolved.document.selection.farm_d_tag.clone(), )); }; + let draft_missing = farm_config::missing_fields(&resolved.document); + if !draft_missing.is_empty() { + return Ok(missing_publish_view( + resolved.scope, + resolved.path.display().to_string(), + args, + "farm draft is missing required fields".to_owned(), + missing_field_labels(draft_missing.as_slice()), + missing_field_actions(draft_missing.as_slice()), + true, + resolved.document.selection.account.clone(), + account.record.public_identity.public_key_hex.clone(), + resolved.document.selection.farm_d_tag.clone(), + )); + } let account_pubkey = account.record.public_identity.public_key_hex.clone(); let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?; let profile_idempotency_key = component_idempotency_key(args, "profile")?; @@ -408,22 +488,29 @@ fn missing_publish_view( path: String, args: &FarmPublishArgs, reason: String, + missing: Vec<String>, + actions: Vec<String>, + config_present: bool, + selected_account_id: String, + selected_account_pubkey: String, + farm_d_tag: String, ) -> FarmPublishView { FarmPublishView { state: "unconfigured".to_owned(), source: daemon::bridge_source().to_owned(), scope: scope.as_str().to_owned(), path, - config_present: false, + config_present, dry_run: false, - selected_account_id: String::new(), - selected_account_pubkey: String::new(), - farm_d_tag: String::new(), + selected_account_id, + selected_account_pubkey, + farm_d_tag, requested_signer_session_id: args.signer_session_id.clone(), profile: not_submitted_component("bridge.profile.publish", KIND_PROFILE, args, None, None), farm: not_submitted_component("bridge.farm.publish", KIND_FARM, args, None, None), + missing, reason: Some(reason), - actions: vec![setup_action(scope), "radroots account whoami".to_owned()], + actions, } } @@ -451,6 +538,7 @@ fn base_publish_view( requested_signer_session_id: args.signer_session_id.clone(), profile, farm, + missing: Vec::new(), reason, actions, } @@ -887,6 +975,312 @@ fn daemon_error_actions(state: &str) -> Vec<String> { } } +fn selected_account_for_draft( + config: &RuntimeConfig, +) -> Result<Option<AccountRecordView>, RuntimeError> { + accounts::resolve_account(config) +} + +fn missing_selected_account_setup_view() -> FarmSetupView { + FarmSetupView { + state: "unconfigured".to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + config: None, + reason: Some("choose or create an account before setting up your farm".to_owned()), + actions: vec!["radroots account create".to_owned()], + } +} + +fn init_document( + scope: FarmConfigScope, + account: &AccountRecordView, + existing: Option<&ResolvedFarmConfig>, + args: &FarmInitArgs, +) -> Result<FarmConfigDocument, RuntimeError> { + let existing_document = existing.map(|resolved| &resolved.document); + 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), + }; + let existing_name = existing_name(existing_document); + let existing_location = existing_location_primary(existing_document); + let existing_city = existing_city(existing_document); + let existing_region = existing_region(existing_document); + let existing_country = existing_country(existing_document); + let existing_delivery = existing_delivery_method(existing_document); + let name = optional_arg_or_existing(args.name.as_ref(), existing_name.as_ref()) + .or_else(|| draft_name_from_account(account)) + .unwrap_or_default(); + 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(|| non_empty(name.as_str())); + 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 location_primary = + optional_arg_or_existing(args.location.as_ref(), existing_location.as_ref()) + .unwrap_or_default(); + let city = optional_arg_or_existing(args.city.as_ref(), existing_city.as_ref()); + let region = optional_arg_or_existing(args.region.as_ref(), existing_region.as_ref()); + let country = optional_arg_or_existing(args.country.as_ref(), existing_country.as_ref()); + let delivery_method = + optional_arg_or_existing(args.delivery_method.as_ref(), existing_delivery.as_ref()) + .unwrap_or_default(); + let publication = publication_for_document(existing_document, account, farm_d_tag.as_str()); + + 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: non_empty(location_primary.as_str()), + 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 save_draft_view( + state: &str, + scope: FarmConfigScope, + account: &AccountRecordView, + document: &FarmConfigDocument, + reason: Option<String>, + actions: Vec<String>, + config: &RuntimeConfig, +) -> Result<FarmSetupView, RuntimeError> { + let written_path = farm_config::write(&config.paths, scope, document)?; + Ok(FarmSetupView { + state: state.to_owned(), + source: FARM_CONFIG_SOURCE.to_owned(), + config: Some(summary_view( + scope, + written_path.display().to_string(), + document, + Some(account.record.public_identity.public_key_hex.as_str()), + )), + reason, + actions, + }) +} + +fn farm_setup_actions(document: &FarmConfigDocument) -> Vec<String> { + let mut actions = vec!["radroots farm check".to_owned()]; + if farm_config::missing_fields(document).is_empty() { + actions.push("radroots farm publish".to_owned()); + } + actions +} + +fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool { + missing.iter().any(|field| { + matches!( + field, + FarmMissingField::Location | FarmMissingField::Delivery + ) + }) +} + +fn missing_field_labels(missing: &[FarmMissingField]) -> Vec<String> { + missing + .iter() + .map(|field| field.label().to_owned()) + .collect() +} + +fn missing_field_actions(missing: &[FarmMissingField]) -> Vec<String> { + let mut actions = Vec::new(); + for field in missing { + match field { + FarmMissingField::Name => { + push_action(&mut actions, "radroots farm set name \"La Huerta Farm\""); + } + FarmMissingField::Location => { + push_action( + &mut actions, + "radroots farm set location \"San Francisco, CA\"", + ); + } + FarmMissingField::Delivery => { + push_action(&mut actions, "radroots farm set delivery pickup"); + } + FarmMissingField::Country => { + push_action(&mut actions, "radroots farm set country US"); + } + } + } + actions +} + +fn push_action(actions: &mut Vec<String>, action: &str) { + if !actions.iter().any(|existing| existing == action) { + actions.push(action.to_owned()); + } +} + +fn human_field_name(field: FarmFieldArg) -> &'static str { + match field { + FarmFieldArg::Name => "Name", + FarmFieldArg::DisplayName => "Display name", + FarmFieldArg::About => "About", + FarmFieldArg::Website => "Website", + FarmFieldArg::Picture => "Picture", + FarmFieldArg::Banner => "Banner", + FarmFieldArg::Location => "Location", + FarmFieldArg::City => "City", + FarmFieldArg::Region => "Region", + FarmFieldArg::Country => "Country", + FarmFieldArg::Delivery => "Delivery", + } +} + +fn human_field_value(field: FarmFieldArg, value: &str) -> String { + match field { + FarmFieldArg::Delivery => humanize_delivery_method(value), + _ => value.to_owned(), + } +} + +fn apply_field_update( + document: &mut FarmConfigDocument, + field: FarmFieldArg, + value: &str, +) -> Result<(), RuntimeError> { + let value = required_text(value, "farm set value")?; + match field { + FarmFieldArg::Name => { + document.profile.name = value.clone(); + document.farm.name = value; + } + FarmFieldArg::DisplayName => { + document.profile.display_name = Some(value); + } + FarmFieldArg::About => { + document.profile.about = Some(value.clone()); + document.farm.about = Some(value); + } + FarmFieldArg::Website => { + document.profile.website = Some(value.clone()); + document.farm.website = Some(value); + } + FarmFieldArg::Picture => { + document.profile.picture = Some(value.clone()); + document.farm.picture = Some(value); + } + FarmFieldArg::Banner => { + document.profile.banner = Some(value.clone()); + document.farm.banner = Some(value); + } + FarmFieldArg::Location => { + document.listing_defaults.location.primary = value.clone(); + ensure_farm_location(document).primary = Some(value); + } + FarmFieldArg::City => { + document.listing_defaults.location.city = Some(value.clone()); + ensure_farm_location(document).city = Some(value); + } + FarmFieldArg::Region => { + document.listing_defaults.location.region = Some(value.clone()); + ensure_farm_location(document).region = Some(value); + } + FarmFieldArg::Country => { + document.listing_defaults.location.country = Some(value.clone()); + ensure_farm_location(document).country = Some(value); + } + FarmFieldArg::Delivery => { + document.listing_defaults.delivery_method = value; + } + } + Ok(()) +} + +fn ensure_farm_location(document: &mut FarmConfigDocument) -> &mut RadrootsFarmLocation { + let primary = non_empty(document.listing_defaults.location.primary.as_str()); + let city = document.listing_defaults.location.city.clone(); + let region = document.listing_defaults.location.region.clone(); + let country = document.listing_defaults.location.country.clone(); + document + .farm + .location + .get_or_insert_with(|| RadrootsFarmLocation { + primary, + city, + region, + country, + gcs: None, + }) +} + +fn publication_for_document( + existing_document: Option<&FarmConfigDocument>, + account: &AccountRecordView, + farm_d_tag: &str, +) -> FarmPublicationStatus { + existing_document + .filter(|document| { + document.farm.d_tag == farm_d_tag + && document.selection.account == account.record.account_id.as_str() + }) + .map(|document| document.publication.clone()) + .unwrap_or_default() +} + fn setup_document( args: &FarmSetupArgs, scope: FarmConfigScope, @@ -951,10 +1345,7 @@ fn setup_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(); + let publication = publication_for_document(existing_document, account, farm_d_tag.as_str()); Ok(FarmConfigDocument { version: SUPPORTED_FARM_CONFIG_VERSION, @@ -1030,13 +1421,9 @@ fn summary_view( 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(), + name: resolved_name(document).unwrap_or_default(), + location_primary: resolved_location_primary(document), + delivery_method: resolved_delivery_method(document).unwrap_or_default(), publication: publication_view(&document.publication), } } @@ -1085,13 +1472,6 @@ fn publish_state(event_id: Option<&str>, published_at: Option<u64>) -> &'static } } -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, @@ -1122,6 +1502,118 @@ fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> .or_else(|| existing.and_then(|value| non_empty(value.as_str()))) } +fn draft_name_from_account(account: &AccountRecordView) -> Option<String> { + account + .record + .label + .as_deref() + .and_then(non_empty) + .or_else(|| non_empty(account.record.account_id.as_str())) +} + +fn existing_name(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document.and_then(resolved_name) +} + +fn existing_location_primary(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document.and_then(resolved_location_primary) +} + +fn existing_city(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document + .and_then(|document| { + document + .farm + .location + .as_ref() + .and_then(|location| location.city.as_ref()) + }) + .and_then(|value| non_empty(value.as_str())) + .or_else(|| { + existing_document + .and_then(|document| document.listing_defaults.location.city.as_ref()) + .and_then(|value| non_empty(value.as_str())) + }) +} + +fn existing_region(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document + .and_then(|document| { + document + .farm + .location + .as_ref() + .and_then(|location| location.region.as_ref()) + }) + .and_then(|value| non_empty(value.as_str())) + .or_else(|| { + existing_document + .and_then(|document| document.listing_defaults.location.region.as_ref()) + .and_then(|value| non_empty(value.as_str())) + }) +} + +fn existing_country(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document + .and_then(|document| { + document + .farm + .location + .as_ref() + .and_then(|location| location.country.as_ref()) + }) + .and_then(|value| non_empty(value.as_str())) + .or_else(|| { + existing_document + .and_then(|document| document.listing_defaults.location.country.as_ref()) + .and_then(|value| non_empty(value.as_str())) + }) +} + +fn existing_delivery_method(existing_document: Option<&FarmConfigDocument>) -> Option<String> { + existing_document + .and_then(|document| non_empty(document.listing_defaults.delivery_method.as_str())) +} + +fn resolved_name(document: &FarmConfigDocument) -> Option<String> { + non_empty(document.profile.name.as_str()).or_else(|| non_empty(document.farm.name.as_str())) +} + +fn resolved_location_primary(document: &FarmConfigDocument) -> Option<String> { + non_empty(document.listing_defaults.location.primary.as_str()).or_else(|| { + document + .farm + .location + .as_ref() + .and_then(|location| location.primary.as_deref()) + .and_then(non_empty) + }) +} + +fn resolved_delivery_method(document: &FarmConfigDocument) -> Option<String> { + non_empty(document.listing_defaults.delivery_method.as_str()) +} + +fn humanize_delivery_method(value: &str) -> String { + value + .split('_') + .filter(|segment| !segment.is_empty()) + .map(capitalize_ascii_word) + .collect::<Vec<_>>() + .join(" ") +} + +fn capitalize_ascii_word(word: &str) -> String { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut rendered = String::new(); + rendered.push(first.to_ascii_uppercase()); + rendered.push_str(chars.as_str()); + rendered +} + fn non_empty(value: &str) -> Option<String> { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs @@ -82,6 +82,25 @@ pub struct ResolvedFarmConfig { pub document: FarmConfigDocument, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FarmMissingField { + Name, + Location, + Delivery, + Country, +} + +impl FarmMissingField { + pub fn label(self) -> &'static str { + match self { + Self::Name => "Farm name", + Self::Location => "Location", + Self::Delivery => "Delivery method", + Self::Country => "Country", + } + } +} + pub fn resolve_scope( paths: &PathsConfig, explicit_scope: Option<FarmConfigScope>, @@ -214,11 +233,6 @@ pub fn validate( .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(), @@ -229,25 +243,73 @@ pub fn validate( "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(), - )); - } 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(), - )); + if !trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { + let _ = document.listing_defaults.delivery_method_model()?; } Ok(()) } +pub fn missing_fields(document: &FarmConfigDocument) -> Vec<FarmMissingField> { + let mut missing = Vec::new(); + + if farm_name(document).is_none() { + missing.push(FarmMissingField::Name); + } + + let location_present = location_primary(document).is_some(); + if !location_present { + missing.push(FarmMissingField::Location); + } + + if trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { + missing.push(FarmMissingField::Delivery); + } + + if location_present && location_country(document).is_none() { + missing.push(FarmMissingField::Country); + } + + missing +} + +fn farm_name(document: &FarmConfigDocument) -> Option<&str> { + non_empty_ref(document.profile.name.as_str()) + .or_else(|| non_empty_ref(document.farm.name.as_str())) +} + +fn location_primary(document: &FarmConfigDocument) -> Option<&str> { + non_empty_ref(document.listing_defaults.location.primary.as_str()).or_else(|| { + document + .farm + .location + .as_ref() + .and_then(|location| location.primary.as_deref()) + .and_then(non_empty_ref) + }) +} + +fn location_country(document: &FarmConfigDocument) -> Option<&str> { + document + .listing_defaults + .location + .country + .as_deref() + .and_then(non_empty_ref) + .or_else(|| { + document + .farm + .location + .as_ref() + .and_then(|location| location.country.as_deref()) + .and_then(non_empty_ref) + }) +} + fn parse_delivery_method(value: &str) -> Result<RadrootsListingDeliveryMethod, RuntimeError> { let method = trimmed(value); if method.is_empty() { @@ -269,6 +331,15 @@ fn trimmed(value: &str) -> &str { value.trim() } +fn non_empty_ref(value: &str) -> Option<&str> { + let trimmed = trimmed(value); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -139,6 +139,9 @@ struct ListingValidationContext { #[derive(Debug, Clone)] struct ListingAuthoringDefaults { farm_config_present: bool, + farm_defaults_ready: bool, + farm_next_action: Option<String>, + farm_reason: Option<String>, selected_account_id: Option<String>, selected_account_pubkey: Option<String>, selected_farm_d_tag: Option<String>, @@ -259,8 +262,8 @@ pub fn scaffold( if defaults.selected_account_pubkey.is_none() { actions.push("radroots account new".to_owned()); } - if !defaults.farm_config_present { - actions.push(farm_setup_action(config)?); + if let Some(action) = &defaults.farm_next_action { + actions.push(action.clone()); } Ok(ListingNewView { @@ -273,10 +276,7 @@ pub fn scaffold( farm_d_tag: defaults.selected_farm_d_tag, delivery_method: non_empty(draft.delivery.method.clone()), location_primary: non_empty(draft.location.primary.clone()), - reason: (!defaults.farm_config_present).then(|| { - "selected farm config not found; delivery, location, and farm defaults were left blank" - .to_owned() - }), + reason: defaults.farm_reason, actions, }) } @@ -1231,6 +1231,12 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults let selected_account = accounts::resolve_account(config)?; let mut defaults = ListingAuthoringDefaults { farm_config_present: false, + farm_defaults_ready: false, + farm_next_action: Some(farm_setup_action(config)?), + farm_reason: Some( + "selected farm draft not found; delivery, location, and farm defaults were left blank" + .to_owned(), + ), selected_account_id: selected_account .as_ref() .map(|account| account.record.account_id.to_string()), @@ -1256,10 +1262,27 @@ fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults defaults.selected_account_id = Some(resolved.document.selection.account.clone()); defaults.selected_account_pubkey = Some(account.record.public_identity.public_key_hex.clone()); defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); - defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone()); - defaults.location = Some(draft_location_from_model( - &resolved.document.listing_defaults.location, - )); + let draft_missing = farm_config::missing_fields(&resolved.document); + defaults.farm_defaults_ready = !draft_missing.iter().any(|field| { + matches!( + field, + farm_config::FarmMissingField::Location | farm_config::FarmMissingField::Delivery + ) + }); + if defaults.farm_defaults_ready { + defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone()); + defaults.location = Some(draft_location_from_model( + &resolved.document.listing_defaults.location, + )); + defaults.farm_next_action = None; + defaults.farm_reason = None; + } else { + defaults.farm_next_action = Some("radroots farm check".to_owned()); + defaults.farm_reason = Some( + "selected farm draft is missing delivery or location defaults; those fields were left blank" + .to_owned(), + ); + } Ok(defaults) } @@ -1284,12 +1307,8 @@ fn draft_location_from_model(location: &RadrootsListingLocation) -> ListingDraft } } -fn farm_setup_action(config: &RuntimeConfig) -> Result<String, RuntimeError> { - let scope = farm_config::resolve_scope(&config.paths, None)?; - Ok(format!( - "radroots farm setup --scope {} --name <farm-name> --location <place>", - scope.as_str() - )) +fn farm_setup_action(_config: &RuntimeConfig) -> Result<String, RuntimeError> { + Ok("radroots farm init".to_owned()) } fn configured_account( diff --git a/tests/farm.rs b/tests/farm.rs @@ -0,0 +1,273 @@ +use std::path::Path; +use std::process::Command; + +use assert_cmd::prelude::*; +use serde_json::Value; +use tempfile::tempdir; + +fn cli_command_in(workdir: &Path) -> Command { + let mut command = Command::cargo_bin("radroots").expect("binary"); + command.current_dir(workdir); + command.env("HOME", workdir.join("home")); + command.env("APPDATA", workdir.join("roaming")); + command.env("LOCALAPPDATA", workdir.join("local")); + for key in [ + "RADROOTS_ENV_FILE", + "RADROOTS_OUTPUT", + "RADROOTS_CLI_LOGGING_FILTER", + "RADROOTS_CLI_LOGGING_OUTPUT_DIR", + "RADROOTS_CLI_LOGGING_STDOUT", + "RADROOTS_CLI_PATHS_PROFILE", + "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT", + "RADROOTS_LOG_FILTER", + "RADROOTS_LOG_DIR", + "RADROOTS_LOG_STDOUT", + "RADROOTS_ACCOUNT", + "RADROOTS_ACCOUNT_SECRET_BACKEND", + "RADROOTS_ACCOUNT_SECRET_FALLBACK", + "RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", + "RADROOTS_IDENTITY_PATH", + "RADROOTS_SIGNER", + "RADROOTS_RELAYS", + "RADROOTS_MYC_EXECUTABLE", + "RADROOTS_RPC_URL", + "RADROOTS_RPC_BEARER_TOKEN", + ] { + command.env_remove(key); + } + command.env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false"); + command +} + +fn json_output(output: &std::process::Output) -> Value { + serde_json::from_slice(output.stdout.as_slice()).expect("json output") +} + +fn json_string_array(json: &Value, field: &str) -> Vec<String> { + json[field] + .as_array() + .expect("array field") + .iter() + .map(|value| value.as_str().expect("string item").to_owned()) + .collect() +} + +#[test] +fn farm_init_requires_a_selected_account() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .args(["--json", "farm", "init"]) + .output() + .expect("run farm init"); + + assert_eq!(output.status.code(), Some(3)); + let json = json_output(&output); + assert_eq!(json["state"], "unconfigured"); + assert_eq!( + json["actions"], + serde_json::json!(["radroots account create"]) + ); +} + +#[test] +fn farm_init_creates_a_minimal_draft_and_reports_missing_fields() { + let dir = tempdir().expect("tempdir"); + + let account = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account.status.success()); + let account_json = json_output(&account); + let account_id = account_json["account"]["id"] + .as_str() + .expect("account id") + .to_owned(); + + let init = cli_command_in(dir.path()) + .args(["--json", "farm", "init"]) + .output() + .expect("run farm init"); + assert!(init.status.success()); + let init_json = json_output(&init); + assert_eq!(init_json["state"], "saved"); + assert_eq!(init_json["config"]["selected_account_id"], account_id); + assert_eq!( + init_json["actions"], + serde_json::json!(["radroots farm check"]) + ); + + let check = cli_command_in(dir.path()) + .args(["farm", "check"]) + .output() + .expect("run farm check"); + assert_eq!(check.status.code(), Some(3)); + let stdout = String::from_utf8(check.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Farm not ready yet")); + assert!(stdout.contains("Missing")); + assert!(stdout.contains("Location")); + assert!(stdout.contains("Delivery method")); + assert!(stdout.contains("radroots farm set location \"San Francisco, CA\"")); + assert!(stdout.contains("radroots farm set delivery pickup")); + + let publish = cli_command_in(dir.path()) + .args(["--json", "farm", "publish"]) + .output() + .expect("run farm publish"); + assert_eq!(publish.status.code(), Some(3)); + let publish_json = json_output(&publish); + assert_eq!(publish_json["state"], "unconfigured"); + let missing = json_string_array(&publish_json, "missing"); + assert!(missing.contains(&"Location".to_owned())); + assert!(missing.contains(&"Delivery method".to_owned())); +} + +#[test] +fn farm_set_updates_the_draft_and_farm_check_turns_ready() { + let dir = tempdir().expect("tempdir"); + + let account = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account.status.success()); + + let init = cli_command_in(dir.path()) + .args([ + "farm", + "init", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--country", + "US", + ]) + .output() + .expect("run farm init"); + assert!(init.status.success()); + + let set = cli_command_in(dir.path()) + .args(["--json", "farm", "set", "delivery", "pickup"]) + .output() + .expect("run farm set"); + assert!(set.status.success()); + let set_json = json_output(&set); + assert_eq!(set_json["state"], "updated"); + assert_eq!(set_json["field"], "Delivery"); + assert_eq!(set_json["value"], "Pickup"); + + let check = cli_command_in(dir.path()) + .args(["--json", "farm", "check"]) + .output() + .expect("run farm check"); + assert!(check.status.success()); + let check_json = json_output(&check); + assert_eq!(check_json["state"], "ready"); + assert_eq!( + check_json["actions"], + serde_json::json!(["radroots farm publish"]) + ); + assert!(check_json.get("missing").is_none()); +} + +#[test] +fn farm_show_reports_a_missing_draft() { + let dir = tempdir().expect("tempdir"); + + let output = cli_command_in(dir.path()) + .args(["farm", "show"]) + .output() + .expect("run farm show"); + + assert_eq!(output.status.code(), Some(3)); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Farm draft not found")); + assert!(stdout.contains("radroots farm init")); +} + +#[test] +fn farm_setup_compatibility_path_still_produces_a_publishable_draft() { + let dir = tempdir().expect("tempdir"); + + let account = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account.status.success()); + + let setup = cli_command_in(dir.path()) + .args([ + "--json", + "farm", + "setup", + "--name", + "La Huerta", + "--location", + "San Francisco, CA", + "--country", + "US", + "--delivery-method", + "pickup", + ]) + .output() + .expect("run farm setup"); + assert!(setup.status.success()); + let setup_json = json_output(&setup); + assert_eq!(setup_json["state"], "configured"); + assert_eq!( + setup_json["actions"], + serde_json::json!(["radroots farm check", "radroots farm publish"]) + ); + + let check = cli_command_in(dir.path()) + .args(["--json", "farm", "check"]) + .output() + .expect("run farm check"); + assert!(check.status.success()); + let check_json = json_output(&check); + assert_eq!(check_json["state"], "ready"); +} + +#[test] +fn listing_new_points_back_to_farm_check_when_defaults_are_incomplete() { + let dir = tempdir().expect("tempdir"); + + let account = cli_command_in(dir.path()) + .args(["account", "new"]) + .output() + .expect("run account new"); + assert!(account.status.success()); + + let init = cli_command_in(dir.path()) + .args(["farm", "init", "--name", "La Huerta"]) + .output() + .expect("run farm init"); + assert!(init.status.success()); + + let listing = cli_command_in(dir.path()) + .args([ + "--json", + "listing", + "new", + "--key", + "eggs", + "--title", + "Pasture eggs", + "--category", + "protein", + "--summary", + "Fresh pasture-raised eggs.", + ]) + .output() + .expect("run listing new"); + assert!(listing.status.success()); + let listing_json = json_output(&listing); + assert_eq!( + listing_json["reason"], + "selected farm draft is missing delivery or location defaults; those fields were left blank" + ); + let actions = json_string_array(&listing_json, "actions"); + assert!(actions.iter().any(|action| action == "radroots farm check")); +} diff --git a/tests/help.rs b/tests/help.rs @@ -52,10 +52,14 @@ fn farm_help_mentions_human_first_subcommands() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("init")); + assert!(stdout.contains("set")); assert!(stdout.contains("check")); assert!(stdout.contains("show")); assert!(stdout.contains("publish")); - assert!(stdout.contains("Compatibility aliases: status, get.")); + assert!(stdout.contains( + "Compatibility paths: `farm setup`, `farm status`, and `farm get` remain available." + )); } #[test] diff --git a/tests/workflow.rs b/tests/workflow.rs @@ -64,7 +64,7 @@ fn setup_seller_creates_local_state_and_reports_farm_attention() { assert!(stdout.contains("Needs attention")); assert!(stdout.contains("Relay configuration")); assert!(stdout.contains("Farm draft")); - assert!(stdout.contains("radroots farm setup")); + assert!(stdout.contains("radroots farm init")); assert!(stdout.contains("radroots status")); let account_output = cli_command_in(dir.path())