#![allow(clippy::unused_self)]
use serde::Serialize;
use serde_json::Value;
use zinc_core::{Account, TxItem};
#[derive(Serialize)]
pub struct BtcBalance {
pub immature: u64,
pub trusted_pending: u64,
pub untrusted_pending: u64,
pub confirmed: u64,
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IntentPairLinkEntry {
pub pairing_id: String,
pub fingerprint: String,
pub agent_pubkey_hex: String,
pub wallet_pubkey_hex: String,
pub status: String,
pub send_allowed: bool,
pub linked_at_unix: i64,
pub status_updated_at_unix: Option<i64>,
pub request_expires_at_unix: i64,
pub ack_expires_at_unix: i64,
pub granted_capabilities: serde_json::Value,
}
#[derive(Serialize)]
#[serde(untagged)]
#[allow(dead_code)]
pub enum CommandOutput {
WalletInit {
profile: Option<String>,
version: u32,
network: String,
scheme: String,
payment_address_type: String,
account_index: u32,
esplora_url: String,
ord_url: String,
pulse_url: String,
bitcoin_cli: String,
bitcoin_cli_args: String,
phrase: String,
#[serde(skip_serializing_if = "Option::is_none")]
words: Option<usize>,
},
WalletImport {
profile: Option<String>,
network: String,
scheme: String,
payment_address_type: String,
account_index: u32,
pulse_url: String,
imported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
phrase: Option<String>,
},
WalletInfo {
profile: Option<String>,
version: u32,
network: String,
scheme: String,
payment_address_type: String,
account_index: u32,
esplora_url: String,
ord_url: String,
pulse_url: String,
bitcoin_cli: String,
bitcoin_cli_args: String,
has_persistence: bool,
has_inscriptions: bool,
updated_at_unix: u64,
},
RevealMnemonic {
phrase: String,
words: usize,
},
Address {
#[serde(rename = "type")]
kind: String,
address: String,
},
Balance {
total: BtcBalance,
spendable: BtcBalance,
inscribed_sats: u64,
},
AccountList {
accounts: Vec<Account>,
},
AccountUse {
previous_account_index: u32,
account_index: u32,
taproot_address: String,
#[serde(skip_serializing_if = "Option::is_none")]
payment_address: Option<String>,
},
TxList {
transactions: Vec<TxItem>,
},
PsbtCreate {
psbt: String,
},
PsbtAnalyze {
analysis: Value,
safe_to_send: bool,
inscription_risk: String,
policy_reasons: Vec<String>,
policy: Value,
},
PsbtSign {
psbt: String,
safe_to_send: bool,
inscription_risk: String,
policy_reasons: Vec<String>,
analysis: Value,
},
PsbtBroadcast {
txid: String,
safe_to_send: bool,
inscription_risk: String,
policy_reasons: Vec<String>,
analysis: Value,
},
SyncChain {
events: Vec<String>,
},
SyncOrdinals {
inscriptions: usize,
},
WaitTxConfirmed {
txid: String,
confirmation_time: Option<u64>,
confirmed: bool,
waited_secs: u64,
},
WaitBalance {
confirmed: u64,
confirmed_balance: u64,
target: u64,
waited_secs: u64,
},
SnapshotSave {
snapshot: String,
},
SnapshotRestore {
restored: String,
},
SnapshotList {
snapshots: Vec<String>,
},
ConfigShow {
config: Value,
},
ConfigSet {
key: String,
value: String,
saved: bool,
},
ConfigUnset {
key: String,
was_set: bool,
saved: bool,
},
LockInfo {
profile: Option<String>,
lock_path: String,
locked: bool,
owner_pid: Option<u32>,
created_at_unix: Option<u64>,
age_secs: Option<u64>,
},
LockClear {
profile: Option<String>,
lock_path: String,
cleared: bool,
},
Doctor {
healthy: bool,
esplora_url: String,
esplora_reachable: bool,
ord_url: String,
ord_reachable: bool,
ord_indexing_height: Option<u64>,
ord_error: Option<String>,
},
InscriptionList {
inscriptions: Vec<zinc_core::ordinals::Inscription>,
display_items: Option<Vec<InscriptionItemDisplay>>, thumb_mode_enabled: bool,
},
OfferCreate {
inscription: String,
ask_sats: u64,
fee_rate_sat_vb: u64,
seller_address: String,
seller_outpoint: String,
seller_pubkey_hex: String,
expires_at_unix: i64,
thumbnail_lines: Option<Vec<String>>,
hide_inscription_ids: bool,
raw_response: serde_json::Value,
},
OfferPublish {
event_id: String,
accepted_relays: u64,
total_relays: u64,
publish_results: Vec<serde_json::Value>,
raw_response: serde_json::Value,
},
OfferDiscover {
event_count: u64,
offer_count: u64,
offers: Vec<serde_json::Value>,
thumbnail_lines: Option<Vec<String>>,
hide_inscription_ids: bool,
raw_response: serde_json::Value,
},
OfferSubmitOrd {
ord_url: String,
submitted: bool,
raw_response: serde_json::Value,
},
OfferListOrd {
ord_url: String,
count: u64,
offers: Vec<serde_json::Value>,
raw_response: serde_json::Value,
},
OfferAccept {
inscription: String,
ask_sats: u64,
txid: String,
dry_run: bool,
inscription_risk: String,
thumbnail_lines: Option<Vec<String>>,
hide_inscription_ids: bool,
raw_response: serde_json::Value,
},
ListingCreate {
inscription: String,
ask_sats: u64,
fee_rate_sat_vb: u64,
seller_outpoint: String,
passthrough_outpoint: String,
seller_pubkey_hex: String,
coordinator_pubkey_hex: String,
expires_at_unix: i64,
raw_response: serde_json::Value,
},
ListingActivate {
inscription: String,
txid: String,
dry_run: bool,
inscription_risk: String,
raw_response: serde_json::Value,
},
ListingPublish {
event_id: String,
accepted_relays: u64,
total_relays: u64,
publish_results: Vec<serde_json::Value>,
raw_response: serde_json::Value,
},
ListingDiscover {
event_count: u64,
listing_count: u64,
listings: Vec<serde_json::Value>,
raw_response: serde_json::Value,
},
ListingBuy {
inscription: String,
ask_sats: u64,
fee_sats: u64,
buyer_input_count: u64,
raw_response: serde_json::Value,
},
ListingCoordinatorSign {
inscription: String,
ask_sats: u64,
raw_response: serde_json::Value,
},
ListingFinalize {
inscription: String,
ask_sats: u64,
txid: String,
broadcast: bool,
raw_response: serde_json::Value,
},
IntentFixtureGenerate {
schema_version: String,
pairing_id: String,
intent_id: String,
signed_pairing_request: serde_json::Value,
signed_pairing_ack: serde_json::Value,
signed_sign_intent: serde_json::Value,
signed_sign_intent_receipt: serde_json::Value,
},
IntentFixtureVerify {
schema_version: String,
valid: bool,
pairing_id: String,
intent_id: String,
},
IntentSend {
pairing_id: String,
fingerprint: String,
intent_id: String,
action: String,
accepted_relays: u64,
total_relays: u64,
},
IntentWaitReceipt {
pairing_id: String,
fingerprint: String,
intent_id: String,
receipt_id: String,
status: String,
signer_pubkey_hex: String,
#[serde(skip_serializing_if = "Option::is_none")]
error_message: Option<String>,
},
IntentPairStart {
schema_version: String,
pairing_id: String,
agent_pubkey_hex: String,
fingerprint: String,
pairing_uri: String,
signed_pairing_request: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pairing_request_json: Option<String>,
request_path: String,
links_path: String,
await_ack: bool,
},
IntentPairAwaitTimeout {
pairing_id: String,
fingerprint: String,
request_path: String,
links_path: String,
},
IntentPairFinish {
paired: bool,
pairing_id: String,
fingerprint: String,
agent_pubkey_hex: String,
wallet_pubkey_hex: String,
granted_capabilities: serde_json::Value,
linked_at_unix: i64,
ack_source: String,
completion_receipt_published: bool,
links_path: String,
},
IntentPairList {
links_path: String,
total: usize,
active: usize,
paused: usize,
revoked: usize,
rotated: usize,
links: Vec<IntentPairLinkEntry>,
},
IntentPairShow {
links_path: String,
link: IntentPairLinkEntry,
},
IntentPairStatusUpdate {
links_path: String,
pairing_id: String,
fingerprint: String,
status: String,
updated_at_unix: i64,
},
Setup {
config_saved: bool,
wizard_used: bool,
profile: Option<String>,
data_dir: String,
password_env: String,
default_network: String,
default_scheme: String,
default_esplora_url: String,
default_ord_url: String,
default_pulse_url: String,
wallet_requested: bool,
wallet_initialized: bool,
wallet_mode: Option<String>,
wallet_phrase: Option<String>,
wallet_word_count: Option<usize>,
},
ScenarioMine {
blocks: u64,
address: String,
raw_output: String,
},
ScenarioFund {
address: String,
amount_btc: String,
txid: String,
mine_blocks: u64,
mine_address: String,
generated_blocks: String,
},
ScenarioReset {
removed: Vec<String>,
},
Message(String),
RawJson(serde_json::Value),
Generic(Value),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InscriptionItemDisplay {
pub number: i64,
pub id: String,
pub value_sats: String,
pub content_type: String,
pub badge_lines: Vec<String>,
pub image_bytes: Option<Vec<u8>>,
}
pub trait Presenter {
fn render(&self, output: &CommandOutput) -> String;
}
fn pluralize(value: u64, unit: &str) -> String {
if value == 1 {
format!("1 {unit}")
} else {
format!("{value} {unit}s")
}
}
fn format_relative_age(updated_at_unix: u64) -> String {
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if updated_at_unix > now_unix {
let diff = updated_at_unix - now_unix;
let future = if diff < 60 {
pluralize(diff, "second")
} else if diff < 3_600 {
pluralize(diff / 60, "minute")
} else if diff < 86_400 {
pluralize(diff / 3_600, "hour")
} else if diff < 604_800 {
pluralize(diff / 86_400, "day")
} else if diff < 2_592_000 {
pluralize(diff / 604_800, "week")
} else if diff < 31_536_000 {
pluralize(diff / 2_592_000, "month")
} else {
pluralize(diff / 31_536_000, "year")
};
return format!("in {future}");
}
let diff = now_unix - updated_at_unix;
if diff < 5 {
"just now".to_string()
} else if diff < 60 {
format!("{} ago", pluralize(diff, "second"))
} else if diff < 3_600 {
format!("{} ago", pluralize(diff / 60, "minute"))
} else if diff < 86_400 {
format!("{} ago", pluralize(diff / 3_600, "hour"))
} else if diff < 604_800 {
format!("{} ago", pluralize(diff / 86_400, "day"))
} else if diff < 2_592_000 {
format!("{} ago", pluralize(diff / 604_800, "week"))
} else if diff < 31_536_000 {
format!("{} ago", pluralize(diff / 2_592_000, "month"))
} else {
format!("{} ago", pluralize(diff / 31_536_000, "year"))
}
}
pub struct AgentPresenter;
impl AgentPresenter {
pub fn new() -> Self {
Self
}
}
impl Presenter for AgentPresenter {
fn render(&self, output: &CommandOutput) -> String {
match output {
CommandOutput::ConfigShow { config } => {
serde_json::to_string_pretty(config).unwrap_or_default()
}
CommandOutput::Setup {
config_saved,
wizard_used,
profile,
data_dir,
password_env,
default_network,
default_scheme,
default_esplora_url,
default_ord_url,
default_pulse_url,
wallet_requested,
wallet_initialized,
wallet_mode,
wallet_phrase,
wallet_word_count,
} => {
let wallet_info = if *wallet_requested || *wallet_initialized {
serde_json::json!({
"wallet_initialized": wallet_initialized,
"mode": wallet_mode,
"phrase": wallet_phrase,
"word_count": wallet_word_count,
})
} else {
serde_json::json!({
"requested": false,
"initialized": false,
})
};
let base = serde_json::json!({
"config_saved": config_saved,
"wizard_used": wizard_used,
"profile": profile,
"data_dir": data_dir,
"password_env": password_env,
"defaults": {
"network": default_network,
"scheme": default_scheme,
"esplora_url": default_esplora_url,
"ord_url": default_ord_url,
"pulse_url": default_pulse_url,
},
"wallet": wallet_info
});
serde_json::to_string_pretty(&base).unwrap_or_default()
}
CommandOutput::Message(msg) => serde_json::to_string(msg).unwrap_or_default(),
CommandOutput::RawJson(val) => serde_json::to_string_pretty(val).unwrap_or_default(),
_ => serde_json::to_string_pretty(output).unwrap_or_default(),
}
}
}
pub struct HumanPresenter {
#[allow(dead_code)]
pub use_color: bool,
}
impl HumanPresenter {
fn render_qr(content: &str) -> Option<String> {
use qrcode::render::unicode;
let qr = qrcode::QrCode::new(content.as_bytes()).ok()?;
Some(qr.render::<unicode::Dense1x2>().build())
}
pub fn new(use_color: bool) -> Self {
Self { use_color }
}
fn print_doctor(&self, output: &CommandOutput) -> String {
if let CommandOutput::Doctor {
healthy,
esplora_url,
esplora_reachable,
ord_url,
ord_reachable,
ord_indexing_height,
ord_error,
} = output
{
use console::style;
let mut out = String::new();
let status = if *healthy {
style("Healthy").green()
} else {
style("Unhealthy").red()
};
out.push_str(&format!("{} {}\n\n", style("Status:").bold(), status));
out.push_str(&format!(
"{} {}\n",
style("Esplora RPC:").bold(),
esplora_url
));
out.push_str(&format!(
" Reachable: {}\n",
if *esplora_reachable {
style("Yes").green()
} else {
style("No").red()
}
));
out.push_str(&format!("{} {}\n", style("Ord RPC:").bold(), ord_url));
out.push_str(&format!(
" Reachable: {}\n",
if *ord_reachable {
style("Yes").green()
} else {
style("No").red()
}
));
if let Some(h) = ord_indexing_height {
out.push_str(&format!(" Height: {}\n", h));
}
if let Some(e) = ord_error {
out.push_str(&format!(" Error: {}\n", style(e).red()));
}
out
} else {
String::new() }
}
fn print_inscription_list(
&self,
inscriptions: &[zinc_core::ordinals::Inscription],
display_items: Option<&Vec<InscriptionItemDisplay>>,
thumb_mode_enabled: bool,
) -> String {
use crate::presenter::thumbnail::print_thumbnail_at;
use console::style;
let mut out = String::new();
if let Some(items) = display_items {
let term_width = {
let (_, cols) = console::Term::stdout().size();
if cols > 0 {
cols as usize
} else {
120
}
};
let card_width: u32 = 24;
let gutter: usize = 2;
let cards_per_row = ((term_width + gutter) / (card_width as usize + gutter)).max(1);
for row_items in items.chunks(cards_per_row) {
for (col, item) in row_items.iter().enumerate() {
let x_offset = col * (card_width as usize + gutter);
let header = format!("{}", style(format!("#{}", item.number)).bold().cyan());
if col > 0 {
print!("\x1b[{}G{header}", x_offset + 1);
} else {
print!("{header}");
}
}
println!();
let space_to_reserve = 14;
for _ in 0..space_to_reserve {
println!();
}
print!("\x1b[{}A", space_to_reserve);
let mut max_img_height: u32 = 0;
for (col, item) in row_items.iter().enumerate() {
let x_offset = col * (card_width as usize + gutter);
if let Some(ref bytes) = item.image_bytes {
let img_height = print_thumbnail_at(bytes, card_width, x_offset as u16);
if let Some((_, h)) = img_height {
max_img_height = max_img_height.max(h);
}
}
}
if max_img_height > 0 {
print!("\x1b[{}B\r", max_img_height);
}
let max_badge_lines = row_items
.iter()
.map(|i| i.badge_lines.len())
.max()
.unwrap_or(0);
for line_idx in 0..max_badge_lines {
for (col, item) in row_items.iter().enumerate() {
if let Some(line) = item.badge_lines.get(line_idx) {
let x_offset = col * (card_width as usize + gutter);
if col > 0 {
print!("\x1b[{}G{line}", x_offset + 1);
} else {
print!("{line}");
}
}
}
println!();
}
println!();
}
if inscriptions.len() > items.len() {
out.push_str(&format!(
"... and {} more inscriptions\n",
inscriptions.len() - items.len()
));
}
} else if !thumb_mode_enabled {
let table = crate::presenter::inscription::format_inscriptions(inscriptions);
out.push_str(&format!("{table}\n"));
}
out
}
fn print_offer_create(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferCreate {
inscription,
ask_sats,
fee_rate_sat_vb,
seller_address,
seller_outpoint,
seller_pubkey_hex,
expires_at_unix,
thumbnail_lines,
hide_inscription_ids,
..
} = output
{
let mut out = String::new();
if let Some(lines) = thumbnail_lines {
for line in lines {
out.push_str(&format!("{line}\n"));
}
}
let mut lines = vec!["OFFER CREATE".to_string()];
if *hide_inscription_ids {
lines.push("inscription: [thumbnail shown above]".to_string());
} else {
lines.push(format!(
"inscription: {}",
crate::commands::offer::abbreviate(inscription, 12, 8)
));
}
lines.push(format!(
"ask: {} sats @ {} sat/vB",
ask_sats, fee_rate_sat_vb
));
lines.push(format!(
"seller input: {}",
crate::commands::offer::abbreviate(seller_address, 12, 8)
));
lines.push(format!(
"outpoint: {}",
crate::commands::offer::abbreviate(seller_outpoint, 16, 6)
));
lines.push(format!(
"seller pubkey: {}",
crate::commands::offer::abbreviate(seller_pubkey_hex, 10, 6)
));
lines.push(format!("expires_at: {expires_at_unix}"));
out.push_str(&format!("{}\n", lines.join("\n")));
out
} else {
String::new()
}
}
fn print_offer_publish(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferPublish {
event_id,
accepted_relays,
total_relays,
publish_results,
..
} = output
{
let mut out = String::new();
let mut lines = vec![
"OFFER PUBLISH".to_string(),
format!(
"event: {}",
crate::commands::offer::abbreviate(event_id, 12, 8)
),
format!("accepted relays: {accepted_relays}/{total_relays}"),
];
for result in publish_results.iter().take(3) {
let relay = result
.get("relay_url")
.and_then(serde_json::Value::as_str)
.unwrap_or("-");
let accepted = result
.get("accepted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let status = if accepted { "ok" } else { "reject" };
lines.push(format!("{status}: {relay}"));
}
if publish_results.len() > 3 {
lines.push(format!("... and {} more relays", publish_results.len() - 3));
}
out.push_str(&format!("{}\n", lines.join("\n")));
out
} else {
String::new()
}
}
fn print_offer_discover(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferDiscover {
event_count,
offer_count,
offers,
thumbnail_lines,
hide_inscription_ids,
..
} = output
{
let mut out = String::new();
if let Some(lines) = thumbnail_lines {
for line in lines {
out.push_str(&format!("{line}\n"));
}
}
let mut lines = vec![
"OFFER DISCOVER".to_string(),
format!("decoded offers: {offer_count} (events: {event_count})"),
];
for (idx, entry) in offers.iter().take(8).enumerate() {
let event_id = entry
.get("event_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("-");
let offer = entry.get("offer").and_then(serde_json::Value::as_object);
let inscription = offer
.and_then(|o| o.get("inscription_id"))
.and_then(serde_json::Value::as_str)
.unwrap_or("-");
let ask_sats = offer
.and_then(|o| o.get("ask_sats"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let seller = offer
.and_then(|o| o.get("seller_pubkey_hex"))
.and_then(serde_json::Value::as_str)
.unwrap_or("-");
if *hide_inscription_ids {
lines.push(format!(
"{:>2}. ask {} sats | seller {} | evt {}",
idx + 1,
ask_sats,
crate::commands::offer::abbreviate(seller, 10, 6),
crate::commands::offer::abbreviate(event_id, 10, 6)
));
} else {
lines.push(format!(
"{:>2}. {} | {} sats | seller {} | evt {}",
idx + 1,
crate::commands::offer::abbreviate(inscription, 12, 8),
ask_sats,
crate::commands::offer::abbreviate(seller, 10, 6),
crate::commands::offer::abbreviate(event_id, 10, 6)
));
}
}
if offers.len() > 8 {
lines.push(format!("... and {} more offers", offers.len() - 8));
}
out.push_str(&format!("{}\n", lines.join("\n")));
out
} else {
String::new()
}
}
fn print_offer_submit_ord(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferSubmitOrd { ord_url, .. } = output {
format!("OFFER SUBMIT-ORD\nsubmitted: true\nord endpoint: {ord_url}\n")
} else {
String::new()
}
}
fn print_offer_list_ord(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferListOrd {
ord_url,
count,
offers,
..
} = output
{
let mut out = String::new();
let mut lines = vec![
"OFFER LIST-ORD".to_string(),
format!("count: {count}"),
format!("ord endpoint: {ord_url}"),
];
for (idx, psbt) in offers.iter().take(3).enumerate() {
if let Some(psbt_str) = psbt.as_str() {
lines.push(format!(
"{:>2}. {}",
idx + 1,
crate::commands::offer::abbreviate(psbt_str, 14, 8)
));
}
}
out.push_str(&format!("{}\n", lines.join("\n")));
out
} else {
String::new()
}
}
fn print_offer_accept(&self, output: &CommandOutput) -> String {
if let CommandOutput::OfferAccept {
inscription,
ask_sats,
txid,
dry_run,
inscription_risk,
thumbnail_lines,
hide_inscription_ids,
..
} = output
{
let mut out = String::new();
if let Some(lines) = thumbnail_lines {
for line in lines {
out.push_str(&format!("{line}\n"));
}
}
let mut lines = vec!["OFFER ACCEPT".to_string()];
if *hide_inscription_ids {
lines.push("inscription: [thumbnail shown above]".to_string());
} else {
lines.push(format!(
"inscription: {}",
crate::commands::offer::abbreviate(inscription, 12, 8)
));
}
lines.push(format!("ask: {ask_sats} sats"));
lines.push(format!(
"mode: {}",
if *dry_run { "dry-run" } else { "broadcast" }
));
lines.push(format!("inscription risk: {inscription_risk}"));
if txid != "-" {
lines.push(format!(
"txid: {}",
crate::commands::offer::abbreviate(txid, 12, 8)
));
}
out.push_str(&format!("{}\n", lines.join("\n")));
out
} else {
String::new()
}
}
fn print_intent_fixture_generate(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentFixtureGenerate {
schema_version,
pairing_id,
intent_id,
..
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT FIXTURE GENERATE").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Schema").dim(),
schema_version
));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Intent ID").dim(),
crate::commands::offer::abbreviate(intent_id, 12, 8)
));
out.push_str(&format!(
"\n{}\n",
style("Use `intent fixture-verify --fixture-file <file>` to validate.").dim()
));
out
} else {
String::new()
}
}
fn print_intent_fixture_verify(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentFixtureVerify {
schema_version,
valid,
pairing_id,
intent_id,
} = output
{
use console::style;
let mut out = String::new();
let status = if *valid {
style("VALID").green().bold()
} else {
style("INVALID").red().bold()
};
out.push_str(&format!("{} {}\n", style("INTENT FIXTURE").bold(), status));
out.push_str(&format!(
" {:<14} {}\n",
style("Schema").dim(),
schema_version
));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Intent ID").dim(),
crate::commands::offer::abbreviate(intent_id, 12, 8)
));
out
} else {
String::new()
}
}
fn print_intent_pair_start(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairStart {
pairing_id,
fingerprint,
pairing_uri,
pairing_request_json,
request_path,
links_path,
await_ack,
..
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT PAIR START").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Request File").dim(),
request_path
));
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing Link").dim(),
pairing_uri
));
if let Some(qr) = Self::render_qr(pairing_uri) {
out.push_str(&format!(
"\n{}\n{}\n",
style("Scan this QR in Zinc wallet:").bold(),
qr
));
}
out.push_str(&format!(
"\n{}\n{}\n",
style("No scanner?").bold(),
style("Copy the Pairing Link above and paste it in wallet pairing.").dim()
));
if *await_ack {
out.push_str(&format!(
"\n{}\n{}\n{}\n",
style("No relay approval received yet").bold(),
style("CLI waited briefly for wallet approval but did not receive an ack.").dim(),
style("Run `zinc-cli pair finish` to retry relay fetch now (or use `--ack-code` fallback).").dim()
));
} else {
out.push_str(&format!(
"\n{}\n{}\n",
style("After wallet approval").bold(),
style("Run `zinc-cli pair finish` (or `--ack-code <code>` fallback).").dim()
));
}
if let Some(request_json) = pairing_request_json {
out.push_str(&format!(
"\n{}\n{}\n",
style("Pairing Request JSON (advanced/debug):").bold(),
request_json
));
}
out
} else {
String::new()
}
}
fn print_intent_send(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentSend {
pairing_id,
fingerprint,
intent_id,
action,
accepted_relays,
total_relays,
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT SENT").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Intent ID").dim(),
crate::commands::offer::abbreviate(intent_id, 12, 8)
));
out.push_str(&format!(" {:<14} {}\n", style("Action").dim(), action));
out.push_str(&format!(
" {:<14} {}/{}\n",
style("Relays").dim(),
accepted_relays,
total_relays
));
out
} else {
String::new()
}
}
fn print_intent_wait_receipt(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentWaitReceipt {
pairing_id,
fingerprint,
intent_id,
receipt_id,
status,
signer_pubkey_hex,
error_message,
} = output
{
use console::style;
let mut out = String::new();
let status_text = if status.eq_ignore_ascii_case("rejected") {
style(status).red().bold().to_string()
} else {
style(status).green().bold().to_string()
};
out.push_str(&format!("{}\n", style("INTENT RECEIPT").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Intent ID").dim(),
crate::commands::offer::abbreviate(intent_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Receipt ID").dim(),
crate::commands::offer::abbreviate(receipt_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Status").dim(),
status_text
));
out.push_str(&format!(
" {:<14} {}\n",
style("Signer").dim(),
crate::commands::offer::abbreviate(signer_pubkey_hex, 12, 8)
));
if let Some(err) = error_message {
out.push_str(&format!(" {:<14} {}\n", style("Reason").dim(), err));
}
out
} else {
String::new()
}
}
fn print_intent_pair_finish(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairFinish {
paired,
pairing_id,
fingerprint,
wallet_pubkey_hex,
ack_source,
completion_receipt_published,
links_path,
..
} = output
{
use console::style;
let mut out = String::new();
let status = if *paired {
style("PAIRED").green().bold()
} else {
style("NOT PAIRED").red().bold()
};
out.push_str(&format!("{} {}\n", style("INTENT PAIR").bold(), status));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Wallet Key").dim(),
crate::commands::offer::abbreviate(wallet_pubkey_hex, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Ack Source").dim(),
ack_source
));
out.push_str(&format!(
" {:<14} {}\n",
style("Receipt Sent").dim(),
if *completion_receipt_published {
"yes"
} else {
"no"
}
));
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out
} else {
String::new()
}
}
fn print_intent_pair_list(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairList {
links_path,
total,
active,
paused,
revoked,
rotated,
links,
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!(
"{} {}\n",
style("INTENT LINKS").bold(),
style(total).bold()
));
out.push_str(&format!(
" {:<14} active={} paused={} revoked={} rotated={}\n",
style("Status").dim(),
active,
paused,
revoked,
rotated
));
if links.is_empty() {
out.push_str(&format!(
" {:<14} {}\n",
style("Links").dim(),
style("No linked agents yet.").dim()
));
} else {
for link in links {
out.push_str(&format!(
" {} {:<8} agent {} wallet {}\n",
crate::commands::offer::abbreviate(&link.pairing_id, 12, 8),
link.status,
crate::commands::offer::abbreviate(&link.agent_pubkey_hex, 8, 6),
crate::commands::offer::abbreviate(&link.wallet_pubkey_hex, 8, 6),
));
}
}
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out
} else {
String::new()
}
}
fn print_intent_pair_show(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairShow { links_path, link } = output {
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT LINK").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(&link.pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
link.fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Status").dim(),
link.status
));
out.push_str(&format!(
" {:<14} {}\n",
style("Can Send").dim(),
if link.send_allowed { "yes" } else { "no" }
));
out.push_str(&format!(
" {:<14} {}\n",
style("Agent Key").dim(),
crate::commands::offer::abbreviate(&link.agent_pubkey_hex, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Wallet Key").dim(),
crate::commands::offer::abbreviate(&link.wallet_pubkey_hex, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Linked At").dim(),
link.linked_at_unix
));
out.push_str(&format!(
" {:<14} {}\n",
style("Status At").dim(),
link.status_updated_at_unix.unwrap_or(link.linked_at_unix)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Req Expires").dim(),
link.request_expires_at_unix
));
out.push_str(&format!(
" {:<14} {}\n",
style("Ack Expires").dim(),
link.ack_expires_at_unix
));
out.push_str(&format!(
" {:<14} {}\n",
style("Policy").dim(),
serde_json::to_string_pretty(&link.granted_capabilities).unwrap_or_default()
));
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out
} else {
String::new()
}
}
fn print_intent_pair_status_update(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairStatusUpdate {
links_path,
pairing_id,
fingerprint,
status,
updated_at_unix,
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT LINK UPDATED").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(" {:<14} {}\n", style("Status").dim(), status));
out.push_str(&format!(
" {:<14} {}\n",
style("Updated At").dim(),
updated_at_unix
));
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out
} else {
String::new()
}
}
fn print_intent_pair_await_timeout(&self, output: &CommandOutput) -> String {
if let CommandOutput::IntentPairAwaitTimeout {
pairing_id,
fingerprint,
request_path,
links_path,
} = output
{
use console::style;
let mut out = String::new();
out.push_str(&format!("{}\n", style("INTENT PAIR").bold()));
out.push_str(&format!(
" {:<14} {}\n",
style("Pairing ID").dim(),
crate::commands::offer::abbreviate(pairing_id, 12, 8)
));
out.push_str(&format!(
" {:<14} {}\n",
style("Fingerprint").dim(),
fingerprint
));
out.push_str(&format!(
" {:<14} {}\n",
style("Request File").dim(),
request_path
));
out.push_str(&format!(
" {:<14} {}\n",
style("Links File").dim(),
links_path
));
out.push_str(&format!(
"\n{}\n{}\n",
style("No relay approval received yet").bold(),
style("Run `zinc-cli pair finish` to retry relay fetch now (or use `--ack-code` fallback).").dim()
));
out
} else {
String::new()
}
}
}
#[cfg(test)]
mod tests {
use super::{AgentPresenter, CommandOutput, HumanPresenter, Presenter};
use serde_json::json;
fn sample_pair_start_output(
pairing_request_json: Option<String>,
await_ack: bool,
) -> CommandOutput {
CommandOutput::IntentPairStart {
schema_version: "pairing-request-v1".to_string(),
pairing_id: "a".repeat(64),
agent_pubkey_hex: "b".repeat(64),
fingerprint: "aaaaaaaaaaaa".to_string(),
pairing_uri: "zinc://pair?request=eyJmb28iOiJiYXIifQ".to_string(),
signed_pairing_request: json!({"request":{"version":1},"signatureHex":"ff"}),
pairing_request_json,
request_path: "/tmp/request.json".to_string(),
links_path: "/tmp/links.json".to_string(),
await_ack,
}
}
#[test]
fn pair_start_human_output_shows_pairing_link_and_qr_section() {
let presenter = HumanPresenter::new(false);
let rendered = presenter.render(&sample_pair_start_output(None, true));
assert!(rendered.contains("Pairing Link"));
assert!(rendered.contains("zinc://pair?request="));
assert!(rendered.contains("Scan this QR in Zinc wallet:"));
assert!(rendered.contains("No relay approval received yet"));
}
#[test]
fn pair_start_human_output_hides_raw_json_by_default() {
let presenter = HumanPresenter::new(false);
let rendered = presenter.render(&sample_pair_start_output(None, true));
assert!(!rendered.contains("Pairing Request JSON (advanced/debug):"));
}
#[test]
fn pair_start_human_output_shows_raw_json_when_present() {
let presenter = HumanPresenter::new(false);
let rendered = presenter.render(&sample_pair_start_output(
Some("{\"x\":1}".to_string()),
true,
));
assert!(rendered.contains("Pairing Request JSON (advanced/debug):"));
assert!(rendered.contains("{\"x\":1}"));
}
#[test]
fn pair_start_human_output_shows_manual_finish_copy_when_no_wait() {
let presenter = HumanPresenter::new(false);
let rendered = presenter.render(&sample_pair_start_output(None, false));
assert!(rendered.contains("After wallet approval"));
assert!(rendered.contains("zinc-cli pair finish"));
}
#[test]
fn agent_presenter_message_renders_valid_json_string() {
let presenter = AgentPresenter::new();
let rendered = presenter.render(&CommandOutput::Message("Logged in.".to_string()));
let parsed: serde_json::Value =
serde_json::from_str(&rendered).expect("agent message should be valid JSON");
assert_eq!(parsed.as_str(), Some("Logged in."));
}
}
impl Presenter for HumanPresenter {
fn render(&self, output: &CommandOutput) -> String {
use console::style;
match output {
CommandOutput::WalletInfo {
profile,
network,
scheme,
payment_address_type,
account_index,
esplora_url,
ord_url,
has_persistence,
has_inscriptions,
updated_at_unix,
..
} => {
let mut out = String::new();
out.push_str(&format!(
" {:<12} {}\n",
style("Profile").dim(),
profile.as_deref().unwrap_or("default")
));
out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network));
out.push_str(&format!(" {:<12} {}\n", style("Scheme").dim(), scheme));
out.push_str(&format!(
" {:<12} {}\n",
style("Payment").dim(),
payment_address_type
));
out.push_str(&format!(
" {:<12} {}\n",
style("Account").dim(),
account_index
));
out.push_str(&format!(
" {:<12} {}\n",
style("Esplora").dim(),
esplora_url
));
out.push_str(&format!(" {:<12} {}\n", style("Ord").dim(), ord_url));
let check = style("✓").green();
let dash = style("-").dim();
out.push_str(&format!(
" {:<12} {}\n",
style("Storage").dim(),
if *has_persistence { &check } else { &dash }
));
out.push_str(&format!(
" {:<12} {}\n",
style("Inscriptions").dim(),
if *has_inscriptions { &check } else { &dash }
));
let time_str = format_relative_age(*updated_at_unix);
out.push_str(&format!(" {:<12} {}\n", style("Updated").dim(), time_str));
out
}
CommandOutput::WalletInit {
profile,
network,
payment_address_type,
phrase,
words,
..
} => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet initialized");
out.push_str(&format!(
" {:<12} {}\n",
style("Profile").dim(),
profile.as_deref().unwrap_or("default")
));
out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network));
out.push_str(&format!(
" {:<12} {}\n",
style("Payment").dim(),
payment_address_type
));
out.push_str(&format!(
" {:<12} {}\n",
style("Phrase").dim(),
if phrase.contains("<hidden") {
style(phrase).dim()
} else {
style(phrase)
}
));
if let Some(w) = words {
out.push_str(&format!(" {:<12} {}\n", style("Words").dim(), w));
}
out
}
CommandOutput::WalletImport {
profile,
network,
payment_address_type,
phrase,
..
} => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet imported");
out.push_str(&format!(
" {:<12} {}\n",
style("Profile").dim(),
profile.as_deref().unwrap_or("default")
));
out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network));
out.push_str(&format!(
" {:<12} {}\n",
style("Payment").dim(),
payment_address_type
));
if let Some(p) = phrase {
out.push_str(&format!(
" {:<12} {}\n",
style("Phrase").dim(),
if p.contains("<hidden") {
style(p).dim()
} else {
style(p)
}
));
}
out
}
CommandOutput::RevealMnemonic { phrase, words } => {
let mut out = String::new();
out.push_str(&format!(" {:<12} {}\n", style("Phrase").dim(), phrase));
out.push_str(&format!(" {:<12} {}\n", style("Words").dim(), words));
out
}
CommandOutput::Address { kind, address } => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("Type").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Address").fg(comfy_table::Color::Green),
]);
table.add_row(vec![
comfy_table::Cell::new(kind),
comfy_table::Cell::new(address),
]);
format!("{table}")
}
CommandOutput::Balance {
total,
spendable,
inscribed_sats,
} => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("Type").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Confirmed (sats)").fg(comfy_table::Color::Green),
comfy_table::Cell::new("Trusted Pending (sats)").fg(comfy_table::Color::Yellow),
comfy_table::Cell::new("Untrusted Pending (sats)")
.fg(comfy_table::Color::Magenta),
]);
table.add_row(vec![
comfy_table::Cell::new("Total (Combined)"),
comfy_table::Cell::new(total.confirmed.to_string()),
comfy_table::Cell::new(total.trusted_pending.to_string()),
comfy_table::Cell::new(total.untrusted_pending.to_string()),
]);
table.add_row(vec![
comfy_table::Cell::new("Spendable (Safe)"),
comfy_table::Cell::new(spendable.confirmed.to_string()),
comfy_table::Cell::new(spendable.trusted_pending.to_string()),
comfy_table::Cell::new(spendable.untrusted_pending.to_string()),
]);
table.add_row(vec![
comfy_table::Cell::new("Inscribed/Protected"),
comfy_table::Cell::new("-"),
comfy_table::Cell::new("-"),
comfy_table::Cell::new(inscribed_sats.to_string()),
]);
format!("{table}")
}
CommandOutput::AccountList { accounts } => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("Index").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Label").fg(comfy_table::Color::Green),
comfy_table::Cell::new("Taproot Address").fg(comfy_table::Color::Yellow),
]);
for account in accounts {
table.add_row(vec![
comfy_table::Cell::new(account.index.to_string()),
comfy_table::Cell::new(&account.label),
comfy_table::Cell::new(&account.taproot_address),
]);
}
format!("{table}")
}
CommandOutput::AccountUse {
account_index,
taproot_address,
payment_address,
..
} => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "Switched account");
out.push_str(&format!(
" {:<12} {}\n",
style("Index").dim(),
account_index
));
out.push_str(&format!(
" {:<12} {}\n",
style("Taproot").dim(),
taproot_address
));
if let Some(payment) = payment_address {
out.push_str(&format!(" {:<12} {}\n", style("Payment").dim(), payment));
}
out
}
CommandOutput::TxList { transactions } => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("TxID").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Type").fg(comfy_table::Color::Green),
comfy_table::Cell::new("Amount (sats)").fg(comfy_table::Color::Yellow),
comfy_table::Cell::new("Fee (sats)").fg(comfy_table::Color::Magenta),
comfy_table::Cell::new("Confirm Time (UTC)").fg(comfy_table::Color::Blue),
comfy_table::Cell::new("Inscription #s").fg(comfy_table::Color::Red),
]);
for tx in transactions {
let time_str = tx
.confirmation_time
.map(|t| {
let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t);
let datetime: chrono::DateTime<chrono::Utc> = d.into();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "Unconfirmed".to_string());
let ins_str = if tx.inscriptions.is_empty() {
"-".to_string()
} else {
tx.inscriptions
.iter()
.map(|i| format!("#{}", i.number))
.collect::<Vec<_>>()
.join(", ")
};
table.add_row(vec![
comfy_table::Cell::new(crate::commands::offer::abbreviate(&tx.txid, 6, 6)),
comfy_table::Cell::new(&tx.tx_type),
comfy_table::Cell::new(tx.amount_sats.to_string()),
comfy_table::Cell::new(tx.fee_sats.to_string()),
comfy_table::Cell::new(time_str),
comfy_table::Cell::new(ins_str),
]);
}
format!("{table}")
}
CommandOutput::PsbtCreate { psbt } => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Created");
out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt));
out
}
CommandOutput::PsbtAnalyze {
safe_to_send,
inscription_risk,
policy_reasons,
..
} => {
let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "PSBT Analysis");
let risk_style = if *safe_to_send {
style(inscription_risk.as_str()).green()
} else {
style(inscription_risk.as_str()).red()
};
out.push_str(&format!(
" {:<12} {}\n",
style("Safe").dim(),
if *safe_to_send { "yes" } else { "no" }
));
out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style));
if !policy_reasons.is_empty() {
out.push_str(&format!(" {:<12}\n", style("Reasons").dim()));
for r in policy_reasons {
out.push_str(&format!(" - {}\n", r));
}
}
out
}
CommandOutput::PsbtSign {
psbt,
safe_to_send,
inscription_risk,
..
} => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Signed");
let risk_style = if *safe_to_send {
style(inscription_risk.as_str()).green()
} else {
style(inscription_risk.as_str()).red()
};
out.push_str(&format!(
" {:<12} {}\n",
style("Safe").dim(),
if *safe_to_send { "yes" } else { "no" }
));
out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style));
out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt));
out
}
CommandOutput::PsbtBroadcast {
txid,
safe_to_send,
inscription_risk,
..
} => {
let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Broadcasted");
let risk_style = if *safe_to_send {
style(inscription_risk.as_str()).green()
} else {
style(inscription_risk.as_str()).red()
};
out.push_str(&format!(
" {:<12} {}\n",
style("Safe").dim(),
if *safe_to_send { "yes" } else { "no" }
));
out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style));
out.push_str(&format!(" {:<12} {}\n", style("TxID").dim(), txid));
out
}
CommandOutput::SyncChain { events } => {
format!(
"{} Synced {} events\n",
style("✓").green().bold(),
events.len()
)
}
CommandOutput::SyncOrdinals { inscriptions } => {
format!(
"{} Synced {} inscriptions\n",
style("✓").green().bold(),
inscriptions
)
}
CommandOutput::WaitTxConfirmed {
txid, waited_secs, ..
} => {
format!(
"{} Tx {} confirmed after {}s\n",
style("✓").green().bold(),
txid,
waited_secs
)
}
CommandOutput::WaitBalance {
confirmed_balance,
target,
waited_secs,
..
} => {
format!(
"{} Target balance {} reached (current: {}) after {}s\n",
style("✓").green().bold(),
target,
confirmed_balance,
waited_secs
)
}
CommandOutput::SnapshotSave { snapshot } => {
format!(
"{} Saved snapshot to {}\n",
style("✓").green().bold(),
snapshot
)
}
CommandOutput::SnapshotRestore { restored } => {
format!(
"{} Restored snapshot from {}\n",
style("✓").green().bold(),
restored
)
}
CommandOutput::SnapshotList { snapshots } => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("Name").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Path").fg(comfy_table::Color::Green),
]);
for path_str in snapshots {
let path = std::path::Path::new(path_str);
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path_str);
table.add_row(vec![
comfy_table::Cell::new(name),
comfy_table::Cell::new(path_str),
]);
}
format!("{table}")
}
CommandOutput::ConfigShow { config } => {
let mut table = comfy_table::Table::new();
table.set_header(vec![
comfy_table::Cell::new("Setting").fg(comfy_table::Color::Cyan),
comfy_table::Cell::new("Value").fg(comfy_table::Color::Green),
]);
if let Some(obj) = config.as_object() {
for (k, v) in obj {
let val_str = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "-".to_string(),
_ => v.to_string(),
};
table.add_row(vec![
comfy_table::Cell::new(k),
comfy_table::Cell::new(val_str),
]);
}
}
format!("{table}")
}
CommandOutput::ConfigSet { key, value, .. } => {
format!("{} Set {} to {}\n", style("✓").green().bold(), key, value)
}
CommandOutput::ConfigUnset { key, was_set, .. } => {
if *was_set {
format!("{} Unset {}\n", style("✓").green().bold(), key)
} else {
format!("{} {} was not set\n", style("ℹ").blue().bold(), key)
}
}
CommandOutput::LockInfo {
lock_path,
locked,
owner_pid,
age_secs,
..
} => {
let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "Lock Status");
let status = if *locked {
style("Locked").red()
} else {
style("Unlocked").green()
};
out.push_str(&format!(" {:<12} {}\n", style("Status").dim(), status));
out.push_str(&format!(" {:<12} {}\n", style("Path").dim(), lock_path));
if let Some(pid) = owner_pid {
out.push_str(&format!(" {:<12} {}\n", style("Owner PID").dim(), pid));
}
if let Some(age) = age_secs {
out.push_str(&format!(" {:<12} {}s\n", style("Age").dim(), age));
}
out
}
CommandOutput::LockClear {
lock_path, cleared, ..
} => {
if *cleared {
format!(
"{} Cleared lock at {}\n",
style("✓").green().bold(),
lock_path
)
} else {
format!(
"{} No lock to clear at {}\n",
style("ℹ").blue().bold(),
lock_path
)
}
}
CommandOutput::Doctor { .. } => self.print_doctor(output),
CommandOutput::Setup {
config_saved,
wizard_used,
profile,
default_network,
default_scheme,
wallet_initialized,
wallet_mode,
wallet_phrase,
..
} => {
let mut out = String::new();
out.push_str(&format!("{} Setup complete\n", style("✓").green().bold()));
out.push_str(&format!(
" {:<15} {}\n",
style("Config Saved:").dim(),
config_saved
));
out.push_str(&format!(
" {:<15} {}\n",
style("Wizard Used:").dim(),
wizard_used
));
out.push_str(&format!(
" {:<15} {}\n",
style("Profile:").dim(),
profile.as_deref().unwrap_or("default")
));
out.push_str(&format!(
" {:<15} {}\n",
style("Network:").dim(),
default_network
));
out.push_str(&format!(
" {:<15} {}\n",
style("Scheme:").dim(),
default_scheme
));
if *wallet_initialized {
out.push_str(&format!(
" {:<15} {}\n",
style("Wallet:").dim(),
"Initialized"
));
if let Some(mode) = wallet_mode {
out.push_str(&format!(" {:<15} {}\n", style("Mode:").dim(), mode));
}
if let Some(phrase) = wallet_phrase {
if phrase == "<hidden; use --reveal to show>" {
out.push_str(&format!(" {:<15} {}\n", style("Phrase:").dim(), phrase));
} else {
out.push_str(&format!(
"\n{}\n{}\n",
style("Mnemonic Phrase (keep this safe!):").red().bold(),
phrase
));
}
}
}
out
}
CommandOutput::ScenarioMine {
blocks,
address,
raw_output,
} => {
format!(
"{} Mined {} blocks to {}\nOutput:\n{}\n",
style("✓").green().bold(),
blocks,
address,
raw_output
)
}
CommandOutput::ScenarioFund {
address,
amount_btc,
txid,
mine_blocks,
mine_address,
generated_blocks,
} => {
format!(
"{} Funded {} with {} BTC\nTxID: {}\n{} Mined {} blocks to {}\nOutput:\n{}\n",
style("✓").green().bold(),
address,
amount_btc,
txid,
style("✓").green().bold(),
mine_blocks,
mine_address,
generated_blocks
)
}
CommandOutput::ScenarioReset { removed } => {
let mut out = format!("{} Scenario Reset\n", style("✓").green().bold());
for path in removed {
out.push_str(&format!(" Removed: {}\n", path));
}
out
}
CommandOutput::InscriptionList {
inscriptions,
display_items,
thumb_mode_enabled,
} => self.print_inscription_list(
inscriptions,
display_items.as_ref(),
*thumb_mode_enabled,
),
CommandOutput::OfferCreate { .. } => self.print_offer_create(output),
CommandOutput::OfferPublish { .. } => self.print_offer_publish(output),
CommandOutput::OfferDiscover { .. } => self.print_offer_discover(output),
CommandOutput::OfferSubmitOrd { .. } => self.print_offer_submit_ord(output),
CommandOutput::OfferListOrd { .. } => self.print_offer_list_ord(output),
CommandOutput::OfferAccept { .. } => self.print_offer_accept(output),
CommandOutput::ListingCreate { .. }
| CommandOutput::ListingActivate { .. }
| CommandOutput::ListingPublish { .. }
| CommandOutput::ListingDiscover { .. }
| CommandOutput::ListingBuy { .. }
| CommandOutput::ListingCoordinatorSign { .. }
| CommandOutput::ListingFinalize { .. } => {
serde_json::to_string_pretty(output).unwrap_or_default()
}
CommandOutput::IntentFixtureGenerate { .. } => {
self.print_intent_fixture_generate(output)
}
CommandOutput::IntentFixtureVerify { .. } => self.print_intent_fixture_verify(output),
CommandOutput::IntentSend { .. } => self.print_intent_send(output),
CommandOutput::IntentWaitReceipt { .. } => self.print_intent_wait_receipt(output),
CommandOutput::IntentPairStart { .. } => self.print_intent_pair_start(output),
CommandOutput::IntentPairAwaitTimeout { .. } => {
self.print_intent_pair_await_timeout(output)
}
CommandOutput::IntentPairFinish { .. } => self.print_intent_pair_finish(output),
CommandOutput::IntentPairList { .. } => self.print_intent_pair_list(output),
CommandOutput::IntentPairShow { .. } => self.print_intent_pair_show(output),
CommandOutput::IntentPairStatusUpdate { .. } => {
self.print_intent_pair_status_update(output)
}
CommandOutput::Message(msg) => msg.clone(),
CommandOutput::RawJson(val) => serde_json::to_string_pretty(val).unwrap_or_default(),
CommandOutput::Generic(val) => {
serde_json::to_string_pretty(val).unwrap_or_default()
}
}
}
}