commit 8d8562ac53e1f3cf0f6758948d0a7b323eaf62a1
parent eb2f23e69651ad16fb6550bb31de6d3de508c51e
Author: triesap <tyson@radroots.org>
Date: Thu, 16 Apr 2026 22:24:42 +0000
rewrite human renderer around verbosity tiers
Diffstat:
7 files changed, 856 insertions(+), 372 deletions(-)
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -13,70 +13,69 @@ use crate::domain::runtime::{
StatusView, SyncActionView, SyncStatusView, SyncWatchView,
};
use crate::runtime::RuntimeError;
-use crate::runtime::config::{OutputConfig, OutputFormat};
+use crate::runtime::config::{OutputConfig, OutputFormat, Verbosity};
const THIN_RULE: &str = "────────────────────────────────────────────────────";
pub fn render_output(output: &CommandOutput, config: &OutputConfig) -> Result<(), RuntimeError> {
match config.format {
- OutputFormat::Human => render_human(output),
+ OutputFormat::Human => render_human(output, config),
OutputFormat::Json => render_json(output),
OutputFormat::Ndjson => render_ndjson(output),
}
}
-fn render_human(output: &CommandOutput) -> Result<(), RuntimeError> {
+fn render_human(output: &CommandOutput, config: &OutputConfig) -> Result<(), RuntimeError> {
let mut stdout = io::stdout().lock();
- render_human_to(&mut stdout, output)
+ render_human_with_config_to(&mut stdout, output, config)
}
+#[cfg(test)]
fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(), RuntimeError> {
+ render_human_with_config_to(stdout, output, &default_human_output_config())
+}
+
+fn render_human_with_config_to(
+ stdout: &mut dyn Write,
+ output: &CommandOutput,
+ config: &OutputConfig,
+) -> Result<(), RuntimeError> {
+ if config.verbosity == Verbosity::Quiet {
+ if let Some(quiet) = render_quiet_output(output) {
+ writeln!(stdout, "{quiet}")?;
+ return Ok(());
+ }
+ }
+
+ let mut buffer = Vec::new();
+ render_human_view_to(&mut buffer, output)?;
+ let rendered = String::from_utf8(buffer).map_err(|error| {
+ RuntimeError::Config(format!("human render output was not utf8: {error}"))
+ })?;
+ let finalized = finalize_human_output(output, rendered, config)?;
+ write!(stdout, "{finalized}")?;
+ Ok(())
+}
+
+#[cfg(test)]
+fn default_human_output_config() -> OutputConfig {
+ OutputConfig {
+ format: OutputFormat::Human,
+ verbosity: Verbosity::Normal,
+ color: true,
+ dry_run: false,
+ }
+}
+
+fn render_human_view_to(
+ stdout: &mut dyn Write,
+ output: &CommandOutput,
+) -> Result<(), RuntimeError> {
match output.view() {
CommandView::AccountList(view) => render_account_list(stdout, view)?,
- CommandView::AccountNew(view) => {
- write_context(stdout, format!("account · {}", view.state).as_str())?;
- render_owned_pairs(
- stdout,
- "account",
- account_pairs(&view.account, Some(&view.public_identity)).as_slice(),
- )?;
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
- }
- CommandView::AccountUse(view) => {
- write_context(stdout, "account · active")?;
- render_owned_pairs(
- stdout,
- "account",
- account_pairs(&view.account, None).as_slice(),
- )?;
- writeln!(stdout, "active account id: {}", view.active_account_id)?;
- writeln!(stdout, "source: {}", view.source)?;
- }
- CommandView::AccountWhoami(view) => {
- write_context(
- stdout,
- match view.state.as_str() {
- "ready" => "account · active",
- "unconfigured" => "account · unconfigured",
- _ => "account",
- },
- )?;
- if let Some(account) = &view.account {
- render_owned_pairs(
- stdout,
- "account",
- account_pairs(account, view.public_identity.as_ref()).as_slice(),
- )?;
- } else {
- writeln!(stdout, "no local account selected")?;
- writeln!(stdout)?;
- }
- if let Some(reason) = &view.reason {
- writeln!(stdout, "reason: {reason}")?;
- }
- writeln!(stdout, "source: {}", view.source)?;
- }
+ CommandView::AccountNew(view) => render_account_new(stdout, view)?,
+ CommandView::AccountUse(view) => render_account_use(stdout, view)?,
+ CommandView::AccountWhoami(view) => render_account_whoami(stdout, view)?,
CommandView::MycStatus(view) => {
render_myc_status(stdout, view, true)?;
}
@@ -591,39 +590,357 @@ fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
+fn render_quiet_output(output: &CommandOutput) -> Option<String> {
+ match output.view() {
+ CommandView::AccountNew(view) => Some(format!(
+ "{}: {}",
+ match view.state.as_str() {
+ "migrated" => "Account migrated",
+ _ => "Account created",
+ },
+ view.account.id
+ )),
+ CommandView::Find(view) | CommandView::MarketSearch(view) => match view.state.as_str() {
+ "ready" if !view.results.is_empty() => Some(
+ view.results
+ .iter()
+ .map(|result| result.product_key.clone())
+ .collect::<Vec<_>>()
+ .join("\n"),
+ ),
+ "empty" => Some("No listings found".to_owned()),
+ _ => None,
+ },
+ CommandView::OrderSubmit(view) => match view.state.as_str() {
+ "accepted" | "submitted" | "already_submitted" | "deduplicated" => {
+ Some(format!("Order submitted: {}", view.order_id))
+ }
+ _ => None,
+ },
+ _ => None,
+ }
+}
+
+fn finalize_human_output(
+ output: &CommandOutput,
+ rendered: String,
+ config: &OutputConfig,
+) -> Result<String, RuntimeError> {
+ let mut cleaned_lines = Vec::new();
+ let mut fallback_details = Vec::<(&'static str, String)>::new();
+
+ for line in rendered.lines() {
+ let trimmed = line.trim_end();
+ if trimmed == THIN_RULE {
+ continue;
+ }
+ if let Some(value) = trimmed.trim_start().strip_prefix("workflow source: ") {
+ fallback_details.push(("Workflow source", value.to_owned()));
+ continue;
+ }
+ if let Some(value) = trimmed.trim_start().strip_prefix("source: ") {
+ fallback_details.push(("Source", value.to_owned()));
+ continue;
+ }
+ if let Some(value) = trimmed.trim_start().strip_prefix("provenance: ") {
+ fallback_details.push(("Provenance", value.to_owned()));
+ continue;
+ }
+ if let Some(value) = trimmed.strip_prefix("reason: ") {
+ cleaned_lines.push(value.to_owned());
+ continue;
+ }
+ if trimmed == "actions" {
+ cleaned_lines.push("Next".to_owned());
+ continue;
+ }
+ cleaned_lines.push(trimmed.to_owned());
+ }
+
+ let cleaned_lines = collapse_blank_lines(cleaned_lines);
+ let mut finalized = cleaned_lines.join("\n");
+ if !finalized.is_empty() && !finalized.ends_with('\n') {
+ finalized.push('\n');
+ }
+
+ if matches!(config.verbosity, Verbosity::Verbose | Verbosity::Trace) {
+ let mut details = verbose_details(output);
+ for fallback in fallback_details {
+ if !details.iter().any(|(label, _)| *label == fallback.0) {
+ details.push(fallback);
+ }
+ }
+ if !details.is_empty() {
+ if !finalized.is_empty() {
+ finalized.push('\n');
+ }
+ finalized.push_str("Details\n");
+ finalized.push_str(render_field_rows_string(details.as_slice()).as_str());
+ }
+ }
+
+ if config.verbosity == Verbosity::Trace {
+ let mut trace_buffer = Vec::new();
+ render_json_to(&mut trace_buffer, output)?;
+ let trace_json = String::from_utf8(trace_buffer).map_err(|error| {
+ RuntimeError::Config(format!("trace render output was not utf8: {error}"))
+ })?;
+ if !finalized.is_empty() {
+ finalized.push('\n');
+ }
+ finalized.push_str("Trace\n");
+ finalized.push_str(
+ render_field_rows_string(&[("Command", human_command_name(output.view()).to_owned())])
+ .as_str(),
+ );
+ for line in trace_json.trim_end().lines() {
+ finalized.push_str(" ");
+ finalized.push_str(line);
+ finalized.push('\n');
+ }
+ }
+
+ Ok(finalized)
+}
+
+fn collapse_blank_lines(lines: Vec<String>) -> Vec<String> {
+ let mut collapsed = Vec::new();
+ let mut previous_blank = true;
+ for line in lines {
+ let blank = line.trim().is_empty();
+ if blank {
+ if previous_blank {
+ continue;
+ }
+ collapsed.push(String::new());
+ } else {
+ collapsed.push(line);
+ }
+ previous_blank = blank;
+ }
+ while collapsed.last().is_some_and(|line| line.trim().is_empty()) {
+ collapsed.pop();
+ }
+ collapsed
+}
+
+fn render_field_rows_string(rows: &[(&str, String)]) -> String {
+ let label_width = rows
+ .iter()
+ .map(|(label, _)| label.len())
+ .max()
+ .unwrap_or_default();
+ let mut rendered = String::new();
+ for (label, value) in rows {
+ rendered.push_str(
+ format!(
+ " {label:label_width$} {value}\n",
+ label_width = label_width
+ )
+ .as_str(),
+ );
+ }
+ rendered
+}
+
+fn verbose_details(output: &CommandOutput) -> Vec<(&'static str, String)> {
+ match output.view() {
+ CommandView::AccountList(view) => vec![("Source", view.source.clone())],
+ CommandView::AccountNew(view) => vec![("Source", view.source.clone())],
+ CommandView::AccountUse(view) => vec![("Source", view.source.clone())],
+ CommandView::AccountWhoami(view) => vec![("Source", view.source.clone())],
+ CommandView::Doctor(view) => vec![("Source", view.source.clone())],
+ CommandView::Find(view) | CommandView::MarketSearch(view) => vec![
+ ("Source", view.source.clone()),
+ ("Freshness", view.freshness.display.clone()),
+ ("Relay count", view.relay_count.to_string()),
+ ],
+ CommandView::ListingGet(view) | CommandView::MarketView(view) => vec![
+ ("Source", view.source.clone()),
+ ("Freshness", view.provenance.freshness.clone()),
+ ("Relay count", view.provenance.relay_count.to_string()),
+ ],
+ CommandView::OrderSubmit(view) => {
+ let mut rows = vec![("Source", view.source.clone())];
+ push_row(
+ &mut rows,
+ "Signer mode",
+ view.signer_mode.as_deref().map(str::to_owned),
+ );
+ push_row(
+ &mut rows,
+ "Requested session",
+ view.requested_signer_session_id
+ .as_deref()
+ .map(str::to_owned),
+ );
+ push_row(
+ &mut rows,
+ "Idempotency key",
+ view.idempotency_key.as_deref().map(str::to_owned),
+ );
+ rows
+ }
+ CommandView::OrderSubmitWatch(view) => {
+ let mut rows = vec![("Source", view.submit.source.clone())];
+ push_row(
+ &mut rows,
+ "Signer mode",
+ view.submit.signer_mode.as_deref().map(str::to_owned),
+ );
+ push_row(
+ &mut rows,
+ "Requested session",
+ view.submit
+ .requested_signer_session_id
+ .as_deref()
+ .map(str::to_owned),
+ );
+ rows
+ }
+ CommandView::RelayList(view) => vec![
+ ("Source", view.source.clone()),
+ ("Relay count", view.count.to_string()),
+ ("Publish policy", view.publish_policy.clone()),
+ ],
+ _ => Vec::new(),
+ }
+}
+
fn present_absent(value: bool) -> &'static str {
if value { "present" } else { "absent" }
}
fn render_account_list(stdout: &mut dyn Write, view: &AccountListView) -> Result<(), RuntimeError> {
- write_context(stdout, format!("accounts · {} local", view.count).as_str())?;
if view.accounts.is_empty() {
- writeln!(stdout, "no accounts found")?;
- writeln!(stdout)?;
- } else {
- let table = Table {
- headers: &["account", "display name", "signer", "default"],
- rows: view
- .accounts
- .iter()
- .map(|account| {
- vec![
- account.id.clone(),
- account.display_name.clone().unwrap_or_default(),
- account.signer.clone(),
- yes_no(account.is_default).to_owned(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
+ writeln!(stdout, "No accounts yet")?;
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ return Ok(());
+ }
+
+ writeln!(
+ stdout,
+ "{} account{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ )?;
+ writeln!(stdout)?;
+ for (index, account) in view.accounts.iter().enumerate() {
+ writeln!(
+ stdout,
+ "{}",
+ account
+ .display_name
+ .as_deref()
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or(account.id.as_str())
+ )?;
+ let rows = vec![
+ ("Account", account.id.clone()),
+ ("Signer", humanize_machine_label(account.signer.as_str())),
+ (
+ "Selected",
+ if account.is_default {
+ "Yes".to_owned()
+ } else {
+ "No".to_owned()
+ },
+ ),
+ ];
+ render_field_rows(stdout, rows.as_slice())?;
+ if index + 1 < view.accounts.len() {
+ writeln!(stdout)?;
+ }
+ }
+ if !view.actions.is_empty() {
writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ Ok(())
+}
+
+fn render_account_new(
+ stdout: &mut dyn Write,
+ view: &crate::domain::runtime::AccountNewView,
+) -> Result<(), RuntimeError> {
+ writeln!(
+ stdout,
+ "{}",
+ match view.state.as_str() {
+ "migrated" => "Account migrated",
+ _ => "Account created",
+ }
+ )?;
+ writeln!(stdout)?;
+ render_account_section(stdout, &view.account)?;
+ writeln!(stdout)?;
+ writeln!(stdout, "Identity")?;
+ render_field_rows(
+ stdout,
+ &[("npub", view.public_identity.public_key_npub.clone())],
+ )?;
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ Ok(())
+}
+
+fn render_account_use(
+ stdout: &mut dyn Write,
+ view: &crate::domain::runtime::AccountUseView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "Account selected")?;
+ writeln!(stdout)?;
+ render_account_section(stdout, &view.account)
+}
+
+fn render_account_whoami(
+ stdout: &mut dyn Write,
+ view: &crate::domain::runtime::AccountWhoamiView,
+) -> Result<(), RuntimeError> {
+ match view.state.as_str() {
+ "ready" => {
+ writeln!(stdout, "Selected account")?;
+ writeln!(stdout)?;
+ if let Some(account) = &view.account {
+ render_account_section(stdout, account)?;
+ }
+ if let Some(identity) = &view.public_identity {
+ writeln!(stdout)?;
+ writeln!(stdout, "Identity")?;
+ render_field_rows(stdout, &[("npub", identity.public_key_npub.clone())])?;
+ }
+ }
+ _ => {
+ writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ writeln!(stdout)?;
+ render_item_section(stdout, "Missing", &["Selected account".to_owned()])?;
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &["radroots account create".to_owned()])?;
+ }
}
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
+fn render_account_section(
+ stdout: &mut dyn Write,
+ account: &AccountSummaryView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "Account")?;
+ let mut rows = Vec::<(&str, String)>::new();
+ push_row(&mut rows, "Name", account.display_name.clone());
+ rows.push(("Account", account.id.clone()));
+ rows.push(("Signer", humanize_machine_label(account.signer.as_str())));
+ render_field_rows(stdout, rows.as_slice())
+}
+
fn render_config_show(
stdout: &mut dyn Write,
view: &crate::domain::runtime::ConfigShowView,
@@ -1131,97 +1448,48 @@ fn format_runtime_target(target_kind: Option<&str>, target: Option<&str>) -> Str
}
fn render_doctor(stdout: &mut dyn Write, view: &DoctorView) -> Result<(), RuntimeError> {
- write_context(stdout, "system · checks")?;
- let table = Table {
- headers: &["check", "status", "detail"],
- rows: view.checks.iter().map(doctor_row).collect(),
- };
- render_table(stdout, &table)?;
- if !view.actions.is_empty() {
+ writeln!(stdout, "Readiness check")?;
+ let ready = view
+ .checks
+ .iter()
+ .filter(|check| matches!(check.status.as_str(), "ok" | "ready" | "healthy"))
+ .map(doctor_item)
+ .collect::<Vec<_>>();
+ let needs_attention = view
+ .checks
+ .iter()
+ .filter(|check| !matches!(check.status.as_str(), "ok" | "ready" | "healthy"))
+ .map(doctor_item)
+ .collect::<Vec<_>>();
+
+ if !ready.is_empty() || !needs_attention.is_empty() || !view.actions.is_empty() {
writeln!(stdout)?;
- writeln!(stdout, "actions")?;
- for action in &view.actions {
- writeln!(stdout, " › {action}")?;
- }
}
- writeln!(stdout)?;
- writeln!(stdout, "source: {}", view.source)?;
- Ok(())
-}
-
-fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> {
- let context = match view.state.as_str() {
- "unconfigured" => "market · local first · unconfigured".to_owned(),
- _ => format!(
- "market · local first · {} result{}",
- view.count,
- if view.count == 1 { "" } else { "s" }
- ),
- };
- write_context(stdout, context.as_str())?;
- writeln!(stdout, "query: {}", view.query)?;
- if let Some(hyf) = &view.hyf {
- writeln!(stdout, "hyf: query rewritten to {}", hyf.rewritten_query)?;
+ let mut wrote_section = false;
+ if !ready.is_empty() {
+ render_item_section(stdout, "Ready", &ready)?;
+ wrote_section = true;
}
-
- match view.state.as_str() {
- "unconfigured" => {
- if let Some(reason) = &view.reason {
- writeln!(stdout, "reason: {reason}")?;
- }
- }
- _ if view.results.is_empty() => {
- if let Some(reason) = &view.reason {
- writeln!(stdout, "{reason}")?;
- }
+ if !needs_attention.is_empty() {
+ if wrote_section {
+ writeln!(stdout)?;
}
- _ => {
- let table = Table {
- headers: &["product", "category", "price", "available", "location"],
- rows: view
- .results
- .iter()
- .map(|result| {
- vec![
- result.title.clone(),
- result.category.clone(),
- format_price(
- result.price.amount,
- &result.price.currency,
- result.price.per_amount,
- &result.price.per_unit,
- ),
- format_available(
- result
- .available
- .available_amount
- .unwrap_or(result.available.total_amount),
- result
- .available
- .label
- .as_deref()
- .unwrap_or(result.available.total_unit.as_str()),
- ),
- result.location_primary.clone().unwrap_or_default(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
+ render_item_section(stdout, "Needs attention", &needs_attention)?;
+ wrote_section = true;
+ }
+ if !view.actions.is_empty() {
+ if wrote_section {
+ writeln!(stdout)?;
}
+ render_item_section(stdout, "Next", &view.actions)?;
}
-
- writeln!(stdout)?;
- writeln!(
- stdout,
- "provenance: local replica · {} · {}",
- view.freshness.display,
- relay_count_text(view.relay_count)
- )?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
+fn render_find(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> {
+ render_market_search(stdout, view)
+}
+
fn render_market_search(stdout: &mut dyn Write, view: &FindView) -> Result<(), RuntimeError> {
match view.state.as_str() {
"unconfigured" => {
@@ -1604,75 +1872,127 @@ fn render_order_list(stdout: &mut dyn Write, view: &OrderListView) -> Result<(),
render_table(stdout, &table)?;
writeln!(stdout)?;
}
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_order_submit(stdout: &mut dyn Write, view: &OrderSubmitView) -> Result<(), RuntimeError> {
+ match view.state.as_str() {
+ "dry_run" => {
+ writeln!(stdout, "Dry run only")?;
+ writeln!(stdout)?;
+ writeln!(stdout, "Order would be submitted.")?;
+ writeln!(stdout)?;
+ render_order_submit_section(stdout, view)?;
+ writeln!(stdout, "Nothing was written.")?;
+ }
+ "missing" => {
+ writeln!(stdout, "Not found")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unconfigured" => {
+ writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.issues.is_empty() {
+ writeln!(stdout)?;
+ writeln!(stdout, "Needs attention")?;
+ let rows = view
+ .issues
+ .iter()
+ .map(|issue| (issue.field.as_str(), issue.message.clone()))
+ .collect::<Vec<_>>();
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "unavailable" => {
+ writeln!(stdout, "Unavailable right now")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ "error" => {
+ writeln!(stdout, "Could not complete the command")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
+ if !view.actions.is_empty() {
+ writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ _ => {
+ writeln!(
+ stdout,
+ "{}",
+ match view.state.as_str() {
+ "already_submitted" => "Order already submitted",
+ "deduplicated" => "Order already in progress",
+ _ => "Order submitted",
+ }
+ )?;
+ writeln!(stdout)?;
+ render_order_submit_section(stdout, view)?;
+ if let Some(job) = &view.job {
+ writeln!(stdout)?;
+ writeln!(stdout, "Job")?;
+ let mut rows = vec![
+ ("Job", job.job_id.clone()),
+ ("State", humanize_machine_label(job.state.as_str())),
+ ];
+ push_row(&mut rows, "Event", job.event_id.clone());
+ render_field_rows(stdout, rows.as_slice())?;
+ }
+ if !view.actions.is_empty() {
+ render_item_section(stdout, "Next", &view.actions)?;
+ }
+ }
+ }
Ok(())
}
-fn render_order_submit(stdout: &mut dyn Write, view: &OrderSubmitView) -> Result<(), RuntimeError> {
- let context = match view.state.as_str() {
- "missing" => format!("order · {} missing", view.order_id),
- "already_submitted" => format!("order · {} already submitted", view.order_id),
- "unconfigured" => format!("order · {} not ready", view.order_id),
- "unavailable" => format!("order · {} submit unavailable", view.order_id),
- "error" => format!("order · {} error", view.order_id),
- "dry_run" => format!("order · {} dry run", view.order_id),
- "deduplicated" => format!("order · {} deduplicated", view.order_id),
- _ => format!("order · {} submitted", view.order_id),
- };
- write_context(stdout, context.as_str())?;
-
- let mut rows = vec![
- ("order id", view.order_id.as_str()),
- ("file", view.file.as_str()),
- ];
- if let Some(listing_lookup) = &view.listing_lookup {
- rows.push(("listing", listing_lookup.as_str()));
- }
- if let Some(listing_addr) = &view.listing_addr {
- rows.push(("listing addr", listing_addr.as_str()));
- }
- if let Some(account_id) = &view.buyer_account_id {
- rows.push(("buyer account", account_id.as_str()));
- }
- if let Some(buyer_pubkey) = &view.buyer_pubkey {
- rows.push(("buyer pubkey", buyer_pubkey.as_str()));
- }
- if let Some(seller_pubkey) = &view.seller_pubkey {
- rows.push(("seller pubkey", seller_pubkey.as_str()));
- }
- if view.dry_run {
- rows.push(("dry run", yes_no(true)));
- }
- if view.deduplicated {
- rows.push(("deduplicated", yes_no(true)));
- }
- if let Some(idempotency_key) = &view.idempotency_key {
- rows.push(("idempotency key", idempotency_key.as_str()));
- }
- if let Some(signer_mode) = &view.signer_mode {
- rows.push(("signer mode", signer_mode.as_str()));
- }
- if let Some(signer_session_id) = &view.signer_session_id {
- rows.push(("signer session", signer_session_id.as_str()));
- }
- if let Some(requested_signer_session_id) = &view.requested_signer_session_id {
- rows.push((
- "requested signer session",
- requested_signer_session_id.as_str(),
- ));
- }
- render_pairs(stdout, "order", rows.as_slice())?;
- if let Some(job) = &view.job {
- render_order_job(stdout, job)?;
- }
- render_order_issues(stdout, &view.issues)?;
- if let Some(reason) = &view.reason {
- writeln!(stdout, "reason: {reason}")?;
+fn render_order_submit_section(
+ stdout: &mut dyn Write,
+ view: &OrderSubmitView,
+) -> Result<(), RuntimeError> {
+ writeln!(stdout, "Order")?;
+ let mut rows = vec![("ID", view.order_id.clone())];
+ push_row(
+ &mut rows,
+ "Listing",
+ first_present([view.listing_lookup.as_deref(), view.listing_addr.as_deref()]),
+ );
+ push_row(
+ &mut rows,
+ "Buyer",
+ first_present([
+ view.buyer_account_id.as_deref(),
+ view.buyer_pubkey.as_deref(),
+ ]),
+ );
+ if !matches!(view.state.as_str(), "dry_run" | "missing" | "unconfigured") {
+ rows.push(("State", humanize_machine_label(view.state.as_str())));
}
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
- Ok(())
+ render_field_rows(stdout, rows.as_slice())
}
fn render_order_submit_watch(
@@ -2066,93 +2386,28 @@ fn render_listing_validate(
}
fn render_listing_get(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(), RuntimeError> {
- let context = view
- .listing_id
- .clone()
- .unwrap_or_else(|| view.lookup.clone());
- write_context(stdout, format!("listing · {context}").as_str())?;
-
- match view.state.as_str() {
- "unconfigured" | "missing" => {
- if let Some(reason) = &view.reason {
- writeln!(stdout, "{reason}")?;
- }
- }
- _ => {
- if let Some(title) = &view.title {
- writeln!(stdout, "{title}")?;
- writeln!(stdout)?;
- }
- let mut rows = Vec::<(&str, String)>::new();
- if let Some(product_key) = &view.product_key {
- rows.push(("key", product_key.clone()));
- }
- if let Some(category) = &view.category {
- rows.push(("category", category.clone()));
- }
- if let Some(price) = &view.price {
- rows.push((
- "price",
- format_price(
- price.amount,
- &price.currency,
- price.per_amount,
- &price.per_unit,
- ),
- ));
- }
- if let Some(available) = &view.available {
- rows.push((
- "available",
- format_available(
- available.available_amount.unwrap_or(available.total_amount),
- available
- .label
- .as_deref()
- .unwrap_or(available.total_unit.as_str()),
- ),
- ));
- }
- if let Some(location_primary) = &view.location_primary {
- rows.push(("location", location_primary.clone()));
- }
- if let Some(listing_id) = &view.listing_id {
- rows.push(("listing id", listing_id.clone()));
- }
- render_owned_pairs(stdout, "listing", rows.as_slice())?;
- if let Some(description) = &view.description {
- writeln!(stdout, "{description}")?;
- writeln!(stdout)?;
- }
- writeln!(
- stdout,
- "provenance: local replica · {} · {}",
- view.provenance.freshness,
- relay_count_text(view.provenance.relay_count)
- )?;
- writeln!(stdout, "source: {}", view.source)?;
- }
- }
-
- if view.state != "ready" {
- writeln!(stdout)?;
- writeln!(stdout, "source: {}", view.source)?;
- }
- render_actions(stdout, &view.actions)?;
- Ok(())
+ render_market_view(stdout, view)
}
fn render_market_view(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(), RuntimeError> {
match view.state.as_str() {
"unconfigured" => {
writeln!(stdout, "Not ready yet")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
if !view.actions.is_empty() {
writeln!(stdout)?;
render_item_section(stdout, "Next", &view.actions)?;
}
}
"missing" => {
- writeln!(stdout, "Listing not found")?;
+ writeln!(stdout, "Not found")?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
+ writeln!(stdout, "{reason}")?;
+ }
if !view.actions.is_empty() {
writeln!(stdout)?;
render_item_section(stdout, "Next", &view.actions)?;
@@ -2212,10 +2467,15 @@ fn render_market_view(stdout: &mut dyn Write, view: &ListingGetView) -> Result<(
));
}
render_owned_pairs(stdout, "Listing", rows.as_slice())?;
+ let mut wrote_about = false;
if let Some(description) = &view.description {
- render_owned_pairs(stdout, "About", &[("Summary", description.clone())])?;
+ render_item_section(stdout, "About", &[description.clone()])?;
+ wrote_about = true;
}
if !view.actions.is_empty() {
+ if wrote_about {
+ writeln!(stdout)?;
+ }
render_item_section(stdout, "Next", &view.actions)?;
}
}
@@ -2523,39 +2783,57 @@ fn render_listing_mutation(
}
fn render_relay_list(stdout: &mut dyn Write, view: &RelayListView) -> Result<(), RuntimeError> {
- write_context(
- stdout,
- match view.state.as_str() {
- "configured" => "relays · configured",
- _ => "relays · unconfigured",
- },
- )?;
if view.relays.is_empty() {
+ writeln!(stdout, "Not ready yet")?;
if let Some(reason) = &view.reason {
+ writeln!(stdout)?;
writeln!(stdout, "{reason}")?;
+ }
+ writeln!(stdout)?;
+ render_item_section(stdout, "Missing", &["Relay configuration".to_owned()])?;
+ if !view.actions.is_empty() {
writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
}
- } else {
- let table = Table {
- headers: &["relay", "read", "write"],
- rows: view
- .relays
- .iter()
- .map(|relay| {
- vec![
- relay.url.clone(),
- yes_no(relay.read).to_owned(),
- yes_no(relay.write).to_owned(),
- ]
- })
- .collect(),
- };
- render_table(stdout, &table)?;
+ return Ok(());
+ }
+
+ writeln!(
+ stdout,
+ "{} relay{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ )?;
+ writeln!(stdout)?;
+ for (index, relay) in view.relays.iter().enumerate() {
+ writeln!(stdout, "{}", relay.url)?;
+ let rows = vec![
+ (
+ "Read",
+ if relay.read {
+ "Yes".to_owned()
+ } else {
+ "No".to_owned()
+ },
+ ),
+ (
+ "Write",
+ if relay.write {
+ "Yes".to_owned()
+ } else {
+ "No".to_owned()
+ },
+ ),
+ ];
+ render_field_rows(stdout, rows.as_slice())?;
+ if index + 1 < view.relays.len() {
+ writeln!(stdout)?;
+ }
+ }
+ if !view.actions.is_empty() {
writeln!(stdout)?;
+ render_item_section(stdout, "Next", &view.actions)?;
}
- writeln!(stdout, "publish policy: {}", view.publish_policy)?;
- writeln!(stdout, "source: {}", view.source)?;
- render_actions(stdout, &view.actions)?;
Ok(())
}
@@ -3363,17 +3641,17 @@ fn render_local_export(stdout: &mut dyn Write, view: &LocalExportView) -> Result
Ok(())
}
-fn doctor_row(check: &DoctorCheckView) -> Vec<String> {
- vec![
- check.name.clone(),
- check.status.clone(),
- check.detail.clone(),
- ]
+fn doctor_item(check: &DoctorCheckView) -> String {
+ let name = humanize_machine_label(check.name.as_str());
+ match non_empty_str(check.detail.as_str()) {
+ Some(detail) => format!("{name}: {detail}"),
+ None => name,
+ }
}
fn write_context(stdout: &mut dyn Write, line: &str) -> Result<(), RuntimeError> {
writeln!(stdout, "{line}")?;
- writeln!(stdout, "{THIN_RULE}")?;
+ writeln!(stdout)?;
Ok(())
}
@@ -3382,9 +3660,9 @@ fn render_actions(stdout: &mut dyn Write, actions: &[String]) -> Result<(), Runt
return Ok(());
}
writeln!(stdout)?;
- writeln!(stdout, "actions")?;
+ writeln!(stdout, "Next")?;
for action in actions {
- writeln!(stdout, " › {action}")?;
+ writeln!(stdout, " {action}")?;
}
Ok(())
}
@@ -3432,25 +3710,6 @@ fn render_owned_pairs(
render_pairs(stdout, heading, borrowed.as_slice())
}
-fn account_pairs(
- account: &AccountSummaryView,
- public_identity: Option<&crate::domain::runtime::IdentityPublicView>,
-) -> Vec<(&'static str, String)> {
- let mut rows = vec![
- ("account id", account.id.clone()),
- ("signer", account.signer.clone()),
- ("default", yes_no(account.is_default).to_owned()),
- ];
- if let Some(display_name) = &account.display_name {
- rows.insert(1, ("display name", display_name.clone()));
- }
- if let Some(public_identity) = public_identity {
- rows.push(("public key npub", public_identity.public_key_npub.clone()));
- rows.push(("public key hex", public_identity.public_key_hex.clone()));
- }
- rows
-}
-
fn render_local_signer(
stdout: &mut dyn Write,
heading: &str,
@@ -3634,14 +3893,6 @@ fn render_table(stdout: &mut dyn Write, table: &Table) -> Result<(), RuntimeErro
Ok(())
}
-fn relay_count_text(count: usize) -> String {
- if count == 1 {
- "1 relay configured".to_owned()
- } else {
- format!("{count} relays configured")
- }
-}
-
fn format_price(amount: f64, currency: &str, per_amount: u32, per_unit: &str) -> String {
format!(
"{} {currency}/{} {per_unit}",
@@ -3766,7 +4017,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
#[cfg(test)]
mod tests {
- use super::{Table, render_human_to, render_ndjson_to, render_table};
+ use super::{
+ Table, render_human_to, render_human_with_config_to, render_ndjson_to, render_table,
+ };
use crate::commands::runtime;
use crate::domain::runtime::{
AccountListView, CommandOutput, CommandView, DoctorCheckView, DoctorView, MycStatusView,
@@ -4117,7 +4370,7 @@ mod tests {
}
#[test]
- fn human_render_doctor_uses_check_table_and_actions() {
+ fn human_render_doctor_uses_readiness_sections() {
let output = CommandOutput::unconfigured(CommandView::Doctor(DoctorView {
ok: false,
state: "warn".to_owned(),
@@ -4139,12 +4392,63 @@ mod tests {
let mut buffer = Vec::new();
render_human_to(&mut buffer, &output).expect("render human");
let rendered = String::from_utf8(buffer).expect("utf8");
- assert!(rendered.contains("system · checks"));
- assert!(rendered.contains("check"));
- assert!(rendered.contains("account warn"));
- assert!(rendered.contains("actions"));
- assert!(rendered.contains("› radroots account new"));
- assert!(rendered.contains("source: local diagnostics"));
+ assert!(rendered.contains("Readiness check"));
+ assert!(rendered.contains("Ready"));
+ assert!(rendered.contains("Config: defaults active"));
+ assert!(rendered.contains("Needs attention"));
+ assert!(rendered.contains("Account: no local account in store"));
+ assert!(rendered.contains("Next"));
+ assert!(rendered.contains("radroots account new"));
+ assert!(!rendered.contains("source: local diagnostics"));
+ }
+
+ #[test]
+ fn human_render_verbose_and_trace_append_diagnostics() {
+ let output = CommandOutput::success(CommandView::Doctor(DoctorView {
+ ok: true,
+ state: "ok".to_owned(),
+ checks: vec![DoctorCheckView {
+ name: "config".to_owned(),
+ status: "ok".to_owned(),
+ detail: "defaults active".to_owned(),
+ }],
+ source: "local diagnostics".to_owned(),
+ actions: Vec::new(),
+ }));
+
+ let mut verbose_buffer = Vec::new();
+ render_human_with_config_to(
+ &mut verbose_buffer,
+ &output,
+ &OutputConfig {
+ format: OutputFormat::Human,
+ verbosity: Verbosity::Verbose,
+ color: false,
+ dry_run: false,
+ },
+ )
+ .expect("render verbose");
+ let verbose_rendered = String::from_utf8(verbose_buffer).expect("utf8");
+ assert!(verbose_rendered.contains("Details"));
+ assert!(verbose_rendered.contains("Source"));
+ assert!(!verbose_rendered.contains("Trace"));
+
+ let mut trace_buffer = Vec::new();
+ render_human_with_config_to(
+ &mut trace_buffer,
+ &output,
+ &OutputConfig {
+ format: OutputFormat::Human,
+ verbosity: Verbosity::Trace,
+ color: false,
+ dry_run: false,
+ },
+ )
+ .expect("render trace");
+ let trace_rendered = String::from_utf8(trace_buffer).expect("utf8");
+ assert!(trace_rendered.contains("Details"));
+ assert!(trace_rendered.contains("Trace"));
+ assert!(trace_rendered.contains("\"source\": \"local diagnostics\""));
}
#[test]
diff --git a/tests/find.rs b/tests/find.rs
@@ -123,7 +123,7 @@ fn find_returns_json_and_ndjson_from_local_market_rows() {
}
#[test]
-fn find_human_output_uses_market_table_and_provenance_footer() {
+fn find_human_output_uses_market_cards_without_internal_footer() {
let dir = tempdir().expect("tempdir");
let init = cli_command_in(dir.path())
.args(["local", "init"])
@@ -150,10 +150,12 @@ fn find_human_output_uses_market_table_and_provenance_footer() {
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
- assert!(stdout.contains("market · local first · 1 result"));
- assert!(stdout.contains("product"));
+ assert!(stdout.contains("1 listing for eggs"));
assert!(stdout.contains("Fresh Eggs"));
- assert!(stdout.contains("provenance: local replica"));
+ assert!(stdout.contains("Key"));
+ assert!(stdout.contains("Price"));
+ assert!(!stdout.contains("provenance:"));
+ assert!(!stdout.contains("source:"));
}
#[test]
@@ -233,7 +235,9 @@ fn find_uses_hyf_query_rewrite_when_available() {
.expect("run hyf human find");
assert!(human_output.status.success());
let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
- assert!(stdout.contains("hyf: query rewritten to eggs"));
+ assert!(stdout.contains("1 listing for eggs"));
+ assert!(stdout.contains("Also searched for"));
+ assert!(stdout.contains("henhouse"));
let ndjson_output = cli_command_in(dir.path())
.env("RADROOTS_HYF_ENABLED", "true")
@@ -250,6 +254,71 @@ fn find_uses_hyf_query_rewrite_when_available() {
}
#[test]
+fn find_human_output_tiers_change_information_budget() {
+ let dir = tempdir().expect("tempdir");
+ let init = cli_command_in(dir.path())
+ .args(["local", "init"])
+ .output()
+ .expect("run local init");
+ assert!(init.status.success());
+
+ seed_trade_product(
+ dir.path(),
+ "00000000-0000-0000-0000-000000000105",
+ "fresh-eggs",
+ "protein",
+ "Fresh Eggs",
+ "Pasture-raised eggs",
+ 36,
+ 24,
+ Some("Marshall"),
+ );
+
+ let quiet_output = cli_command_in(dir.path())
+ .args(["--quiet", "find", "eggs"])
+ .output()
+ .expect("run quiet find");
+ assert!(quiet_output.status.success());
+ let quiet_stdout = String::from_utf8(quiet_output.stdout).expect("utf8 stdout");
+ assert_eq!(quiet_stdout.trim(), "fresh-eggs");
+
+ let default_output = cli_command_in(dir.path())
+ .args(["find", "eggs"])
+ .output()
+ .expect("run default find");
+ assert!(default_output.status.success());
+ let default_stdout = String::from_utf8(default_output.stdout).expect("utf8 stdout");
+ assert!(default_stdout.contains("1 listing for eggs"));
+ assert!(!default_stdout.contains("Details"));
+ assert!(!default_stdout.contains("Trace"));
+ assert!(!default_stdout.contains("Source"));
+
+ let verbose_output = cli_command_in(dir.path())
+ .args(["--verbose", "find", "eggs"])
+ .output()
+ .expect("run verbose find");
+ assert!(verbose_output.status.success());
+ let verbose_stdout = String::from_utf8(verbose_output.stdout).expect("utf8 stdout");
+ assert!(verbose_stdout.contains("1 listing for eggs"));
+ assert!(verbose_stdout.contains("Details"));
+ assert!(verbose_stdout.contains("Source"));
+ assert!(verbose_stdout.contains("Freshness"));
+ assert!(verbose_stdout.contains("Relay count"));
+ assert!(!verbose_stdout.contains("Trace"));
+
+ let trace_output = cli_command_in(dir.path())
+ .args(["--trace", "find", "eggs"])
+ .output()
+ .expect("run trace find");
+ assert!(trace_output.status.success());
+ let trace_stdout = String::from_utf8(trace_output.stdout).expect("utf8 stdout");
+ assert!(trace_stdout.contains("Details"));
+ assert!(trace_stdout.contains("Trace"));
+ assert!(trace_stdout.contains("Command"));
+ assert!(trace_stdout.contains("\"source\""));
+}
+
+#[test]
fn find_uses_hyf_query_rewrite_without_status_preflight() {
let dir = tempdir().expect("tempdir");
let init = cli_command_in(dir.path())
diff --git a/tests/identity_commands.rs b/tests/identity_commands.rs
@@ -82,6 +82,22 @@ fn account_new_json_creates_local_account_store_entry() {
}
#[test]
+fn account_create_quiet_reports_created_account_id() {
+ let dir = tempdir().expect("tempdir");
+
+ let output = cli_command_in(dir.path())
+ .args(["--quiet", "account", "create"])
+ .output()
+ .expect("run quiet account create");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let line = stdout.trim();
+ assert!(line.starts_with("Account created: "));
+ assert!(line.len() > "Account created: ".len());
+}
+
+#[test]
fn account_new_encrypts_file_backed_secret_fallback_by_default() {
let dir = tempdir().expect("tempdir");
diff --git a/tests/listing.rs b/tests/listing.rs
@@ -394,9 +394,13 @@ fn listing_get_reads_real_local_rows_and_reports_missing() {
.expect("run human listing get");
assert!(human_output.status.success());
let stdout = String::from_utf8(human_output.stdout).expect("utf8 stdout");
- assert!(stdout.contains("listing ·"));
assert!(stdout.contains("Pasture Eggs"));
- assert!(stdout.contains("provenance: local replica"));
+ assert!(stdout.contains("Listing"));
+ assert!(stdout.contains("Key"));
+ assert!(stdout.contains("Place"));
+ assert!(stdout.contains("About"));
+ assert!(!stdout.contains("listing ·"));
+ assert!(!stdout.contains("provenance:"));
let missing_output = cli_command_in(dir.path())
.args(["--json", "listing", "get", "missing-listing"])
diff --git a/tests/market.rs b/tests/market.rs
@@ -290,6 +290,14 @@ fn market_search_preserves_machine_shape_and_renders_card_list() {
assert!(stdout.contains("radroots market view sf-tomatoes"));
assert!(stdout.contains("radroots order create --listing sf-tomatoes"));
assert!(!stdout.contains("market · local first"));
+
+ let quiet_output = cli_command_in(dir.path())
+ .args(["--quiet", "market", "search", "tomatoes"])
+ .output()
+ .expect("run quiet market search");
+ assert!(quiet_output.status.success());
+ let quiet_stdout = String::from_utf8(quiet_output.stdout).expect("utf8 stdout");
+ assert_eq!(quiet_stdout.trim(), "sf-tomatoes");
}
#[test]
diff --git a/tests/order.rs b/tests/order.rs
@@ -726,6 +726,81 @@ fn order_submit_persists_submission_metadata_and_reports_job() {
}
#[test]
+fn order_submit_quiet_reports_submitted_order_id() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+
+ let new_output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ ])
+ .output()
+ .expect("run order new");
+ assert!(new_output.status.success());
+ let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json");
+ let order_id = new_json["order_id"].as_str().expect("order id");
+ let buyer_pubkey = new_json["buyer_pubkey"]
+ .as_str()
+ .expect("buyer pubkey")
+ .to_owned();
+
+ let server = MockRpcServer::start(move |body, _auth_header| {
+ match body["method"].as_str().unwrap_or_default() {
+ "nip46.session.list" => MockRpcResponse::success(json!([sample_session(
+ "sess_order_quiet_01",
+ buyer_pubkey.as_str(),
+ &["sign_event"],
+ true
+ )])),
+ "bridge.order.request" => MockRpcResponse::success(serde_json::json!({
+ "deduplicated": false,
+ "job": sample_bridge_job("job_order_quiet_01", "accepted", false, "sess_order_quiet_01"),
+ })),
+ "bridge.job.status" => MockRpcResponse::success(sample_bridge_job(
+ "job_order_quiet_01",
+ "accepted",
+ false,
+ "sess_order_quiet_01",
+ )),
+ other => panic!("unexpected mock rpc method {other}"),
+ }
+ });
+ write_workspace_config(
+ dir.path(),
+ workspace_config_with_write_plane("", server.url().as_str()).as_str(),
+ );
+
+ let submit_output = order_command_in(dir.path())
+ .env("RADROOTS_RPC_BEARER_TOKEN", "quiet-token")
+ .args([
+ "--quiet",
+ "order",
+ "submit",
+ order_id,
+ "--signer-session-id",
+ "sess_order_quiet_01",
+ ])
+ .output()
+ .expect("run quiet order submit");
+ assert!(submit_output.status.success());
+ let stdout = String::from_utf8(submit_output.stdout).expect("utf8 stdout");
+ assert_eq!(stdout.trim(), format!("Order submitted: {order_id}"));
+}
+
+#[test]
fn order_submit_watch_rejects_json_output() {
let _guard = order_test_guard();
let dir = tempdir().expect("tempdir");
@@ -1034,7 +1109,13 @@ managed_account_ref = "{account_id}"
.output()
.expect("run order submit");
- assert!(submit_output.status.success());
+ let stdout = String::from_utf8(submit_output.stdout.clone()).expect("stdout utf8");
+ let stderr = String::from_utf8(submit_output.stderr.clone()).expect("stderr utf8");
+ assert!(
+ submit_output.status.success(),
+ "status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}",
+ submit_output.status.code()
+ );
let submit_json: Value =
serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json");
assert_eq!(submit_json["state"], "accepted");
diff --git a/tests/relay_net.rs b/tests/relay_net.rs
@@ -100,7 +100,9 @@ fn relay_ls_without_relays_exits_unconfigured() {
assert_eq!(output.status.code(), Some(3));
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
- assert!(stdout.contains("relays · unconfigured"));
+ assert!(stdout.contains("Not ready yet"));
+ assert!(stdout.contains("Missing"));
+ assert!(stdout.contains("Relay configuration"));
assert!(stdout.contains("no relays are configured"));
}