urma-cli 0.1.0

Command-line tools for URMA identities, wallets, Wire, archives and Git
#![deny(
    unused_must_use,
    for_loops_over_fallibles,
    dead_code,
    unused_variables,
    unused_assignments
)]
mod archive_cli;
mod capture_cli;
mod common_cli;
mod config;
mod files_cli;
mod funding_cli;
mod git_cli;
mod git_publish_cli;
mod key_cli;
mod litecoin_cli;
mod node_cli;
mod public_cli;
mod wallet_cli;
mod wire_live_cli;
use clap::{Parser, Subcommand};
use serde_json::Value;
use urma_runtime::error::Error;

#[derive(Parser)]
#[command(
    name = "urma",
    version,
    about = "Publish, recover and use URMA content",
    after_help = "Litecoin mainnet by default. Add --testnet for Litecoin testnet.\n\nStart here:\n  urma git clone <TXID>\n  urma git clone <TXID> --testnet\n  urma wallet address\n\nNo node options needed. Advanced configuration: see CLI.md."
)]
struct Cli {
    #[arg(short = 'v', global = true, default_value_t = 0, value_parser = clap::value_parser!(u8).range(0..=3), help = "Verbosity: 1 summary details, 2 plan details, 3 transaction diagnostics")]
    verbosity: u8,
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    #[command(about = "Publish and clone committed Git snapshots")]
    Git {
        #[command(subcommand)]
        command: git_cli::GitCommand,
    },
    #[command(about = "Publish public text and read your local feed")]
    Wire {
        #[command(subcommand)]
        command: public_cli::PublicCommand,
    },
    #[command(about = "Encrypt and recover files, directories and collections")]
    Archive {
        #[command(subcommand)]
        command: files_cli::Command,
    },
    #[command(about = "Preserve captured originals and their context")]
    Capture {
        #[command(subcommand)]
        command: capture_cli::Command,
    },
    #[command(about = "Manage your identities and recovery backups")]
    Key {
        #[command(subcommand)]
        command: key_cli::KeyCommand,
    },
    #[command(about = "Show your address, funds and fee estimates")]
    Wallet {
        #[command(subcommand)]
        command: wallet_cli::WalletCommand,
    },
    #[command(about = "Low-level protocol tools for developers")]
    Expert {
        #[command(subcommand)]
        command: archive_cli::Command,
    },
}

pub(crate) fn progress(message: String) {
    eprintln!("{message}");
}

pub(crate) fn print_report(value: Value) -> Result<(), Error> {
    if value.is_null() {
        return Ok(());
    }
    match config::output()? {
        config::Output::Json => println!("{}", serde_json::to_string_pretty(&value)?),
        config::Output::Human => render_report("", &value, 0),
    }
    Ok(())
}

fn run() -> Result<(), Error> {
    let cli = Cli::parse();
    config::set_verbosity(cli.verbosity);
    install_progress_logging(cli.verbosity)?;
    match cli.command {
        Command::Archive { command } => print_report(files_cli::run(command)?),
        Command::Expert { command } => archive_cli::run(command),
        Command::Wire { command } => print_report(public_cli::run(command)?),
        Command::Git { command } => print_report(git_cli::run(command)?),
        Command::Capture { command } => print_report(capture_cli::run(command)?),
        Command::Wallet { command } => print_report(wallet_cli::run(command)?),
        Command::Key { command } => {
            let report = key_cli::run(command)?;
            if report != Value::Null {
                print_report(report)?;
            }
            Ok(())
        }
    }
}

fn main() {
    match run() {
        Ok(()) => {}
        Err(error) => {
            eprintln!("error: {error}");
            std::process::exit(1);
        }
    }
}

fn render_report(label: &str, value: &Value, depth: usize) {
    let indent = "  ".repeat(depth);
    let label = label.replace('_', " ");
    match value {
        Value::Null => {}
        Value::Object(fields) => {
            if !label.is_empty() {
                println!("{indent}{label}:");
            }
            for (key, value) in fields {
                if matches!(key.as_str(), "commit" | "reveal" | "record_hex") && value.is_string() {
                    println!(
                        "{indent}  {}: retained in plan/proof files",
                        key.replace('_', " ")
                    );
                } else {
                    render_report(key, value, depth + usize::from(!label.is_empty()));
                }
            }
        }
        Value::Array(items) => {
            println!("{indent}{label}: {} entries", items.len());
            if items.len() <= 10 && depth < 3 {
                for (index, item) in items.iter().enumerate() {
                    render_report(&format!("{}", index + 1), item, depth + 1);
                }
            }
        }
        Value::String(text) => println!("{indent}{label}: {}", terminal_text(text)),
        Value::Bool(value) => println!("{indent}{label}: {}", if *value { "yes" } else { "no" }),
        Value::Number(number) => println!("{indent}{label}: {number}"),
    }
}

fn terminal_text(text: &str) -> String {
    let mut safe = String::new();
    for character in text.chars() {
        safe.extend(character.escape_debug());
    }
    safe
}

pub(crate) fn approve_publication(label: &str, id: &str, fee: u64, yes: bool) -> Result<(), Error> {
    use std::io::{IsTerminal, Write};
    eprintln!("{label}\nPlan: {id}\nTotal fee: {fee} base units.");
    if yes {
        return Ok(());
    }
    urma_runtime::error::ensure!(
        std::io::stdin().is_terminal(),
        "publication needs approval; review the plan, then run this command with --yes"
    );
    eprint!("Publish this exact plan? [y/N] ");
    std::io::stderr().flush()?;
    let mut answer = String::new();
    std::io::stdin().read_line(&mut answer)?;
    urma_runtime::error::ensure!(
        matches!(answer.trim(), "y" | "Y" | "yes"),
        "publication cancelled; nothing submitted"
    );
    Ok(())
}

pub(crate) fn detail(level: u8, message: String) {
    if config::verbosity() >= level {
        eprintln!("{message}");
    }
}

fn install_progress_logging(verbosity: u8) -> Result<(), Error> {
    use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt};
    let level = match verbosity {
        0 | 1 => LevelFilter::INFO,
        2 => LevelFilter::DEBUG,
        _ => LevelFilter::TRACE,
    };
    let filter = tracing_subscriber::filter::Targets::new().with_target("urma_progress", level);
    tracing_subscriber::registry()
        .with(filter)
        .with(
            tracing_subscriber::fmt::layer()
                .without_time()
                .with_target(false)
                .with_level(false)
                .with_ansi(false)
                .with_writer(std::io::stderr),
        )
        .try_init()
        .map_err(|cause| Error::Io(std::io::Error::other(cause)))
}

pub(crate) fn stage<T>(label: &str, operation: impl FnOnce() -> T) -> T {
    use std::{
        sync::mpsc,
        time::{Duration, Instant},
    };
    progress(label.to_owned());
    let started = Instant::now();
    let (sender, receiver) = mpsc::channel::<()>();
    std::thread::scope(|scope| {
        scope.spawn(move || {
            loop {
                match receiver.recv_timeout(Duration::from_secs(10)) {
                    Ok(()) => break,
                    Err(cause @ mpsc::RecvTimeoutError::Disconnected) => {
                        tracing::warn!(reason = %cause, "Progress stage finished");
                        break;
                    }
                    Err(cause @ mpsc::RecvTimeoutError::Timeout) => {
                        tracing::warn!(reason = %cause, "Progress timer tick");
                        eprintln!("{label} ({}s elapsed)", started.elapsed().as_secs());
                    }
                }
            }
        });
        let result = operation();
        drop(sender);
        result
    })
}