commit b97c057872fdbdf6be3599d0396f684ea1e35a49
parent 26deb5b552da91da235c5dc42971c7e4295e993a
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 21:01:05 +0000
implement iterative farm drafting
Diffstat:
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())