urma-cli 0.1.0

Command-line tools for URMA identities, wallets, Wire, archives and Git
use crate::{
    approve_publication, config, detail, funding_cli, git_publish_cli, key_cli::VaultAccess,
    node_cli::NodeArgs, print_report, progress, stage,
};
use clap::{Args, Subcommand};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use urma_git::{inventory::Limits, workflows};
use urma_runtime::error::Error;
use urma_runtime::plan::PlanLimits;

#[derive(Args)]
pub(crate) struct GitLimits {}

impl GitLimits {
    fn load(&self) -> Result<Limits, Error> {
        config::git_limits()
    }
}

#[derive(Args)]
pub(crate) struct PrepareArgs {
    #[arg(default_value = ".")]
    repo: PathBuf,
    #[arg(long, help = "Public repository name [default: source directory name]")]
    name: Option<String>,
    #[arg(long, default_value = ".urma-plan")]
    output: PathBuf,
    #[command(flatten)]
    node: NodeArgs,
    #[command(flatten)]
    access: VaultAccess,
    #[command(flatten)]
    resources: GitLimits,
    #[arg(long, default_value_t = 1)]
    fee_rate: u64,
    #[arg(
        long,
        default_value_t = 100_000,
        help = "Maximum total fee in litoshis; planning never broadcasts"
    )]
    max_fee: u64,
}

#[derive(Args)]
pub(crate) struct ResumeArgs {
    #[arg(long, default_value = ".urma-plan")]
    plan: PathBuf,
    #[arg(long)]
    #[arg(
        short,
        help = "Approve the displayed plan and exact fee without a prompt"
    )]
    yes: bool,
    #[command(flatten)]
    node: NodeArgs,
}

#[derive(Args)]
pub(crate) struct RecoverArgs {
    root: String,
    #[arg(long)]
    output: PathBuf,
    #[command(flatten)]
    node: NodeArgs,
    #[command(flatten)]
    resources: GitLimits,
}

#[derive(Subcommand)]
pub(crate) enum GitCommand {
    #[command(about = "Freeze HEAD, scan content and quote publication (no broadcast)")]
    Prepare(PrepareArgs),
    #[command(about = "Show the frozen content, funding and fee")]
    Inspect {
        #[arg(long, default_value = ".urma-plan")]
        plan: PathBuf,
    },
    #[command(about = "Record content review for the frozen plan")]
    Review {
        #[arg(long, default_value = ".urma-plan")]
        plan: PathBuf,
        #[arg(long)]
        classify_public_test_material: Vec<String>,
    },
    #[command(about = "Prepare the current repository, show its price and ask before publishing")]
    Publish(git_publish_cli::PublishArgs),
    #[command(about = "Continue the same publication after confirmation")]
    Resume(ResumeArgs),
    #[command(about = "Recover content and transaction proofs without checkout")]
    Recover(RecoverArgs),
    #[command(
        about = "Clone a published Git snapshot into an editable repository",
        after_help = "Examples:
  urma git clone <TXID>
  urma git clone <TXID> --testnet
  urma git clone <TXID> my-project

Litecoin mainnet is the default. No wallet or account is needed to clone."
    )]
    Clone {
        #[arg(value_name = "TXID", help = "URMA Git root transaction ID")]
        root: bitcoin::Txid,
        #[arg(
            help = "Destination directory [default: published repository name; TXID for unnamed roots]"
        )]
        directory: Option<PathBuf>,
        #[command(flatten)]
        node: NodeArgs,
        #[command(flatten)]
        resources: GitLimits,
    },
    #[command(about = "Verify retained transaction proofs and Git content offline")]
    Verify {
        #[arg(long)]
        snapshot: PathBuf,
        #[command(flatten)]
        resources: GitLimits,
    },
}

fn boundary(error: urma_git::error::Error) -> Error {
    Error::Io(std::io::Error::other(error))
}

fn prepare(args: PrepareArgs) -> Result<Value, Error> {
    let guard = urma_git::workspace::lock(&args.output).map_err(boundary)?;
    urma_git::workspace::ensure_unpublished(&args.output).map_err(boundary)?;
    let name = urma_git::config::publication_name(
        &args.repo,
        urma_git::config::PublicationName(args.name),
    )
    .map_err(boundary)?;
    progress(format!(
        "Preparing {name} on {}. No broadcast.",
        args.node.chain()?.label()
    ));
    detail(
        1,
        format!(
            "Plan directory: {}; fee ceiling: {} base units; rate: {} base units/vB.",
            args.output.display(),
            args.max_fee,
            args.fee_rate
        ),
    );
    let node = stage("Connecting to the selected network...", || {
        args.node.connect()
    })?;
    let vault = stage("Unlocking the active identity...", || args.access.open())?;
    let signer = vault.keyring().active()?;
    let limits = args.resources.load()?;
    stage("Preparing and validating the Git snapshot...", || {
        urma_git::workspace::snapshot(&args.repo, &args.output, &limits, &name)
    })
    .map_err(boundary)?;
    let length = args.output.join("object.bin").metadata()?.len();
    let quote = urma_runtime::quote::multipart(length, &signer, args.fee_rate, args.node.chain()?)?;
    funding_cli::preview(node.chain(), &quote, args.max_fee);
    funding_cli::check(&node, &signer, &quote, args.max_fee)?;
    let report = stage("Signing and verifying the publication plan...", || {
        workflows::prepare_snapshot(
            &node,
            &signer,
            &args.output,
            &limits,
            PlanLimits {
                fee_rate: args.fee_rate,
                max_fee: quote.maximum_fee,
                max_records: quote.records,
            },
        )
    })
    .map_err(boundary)?;
    funding_cli::preflight(&node, &args.output)?;
    drop(guard);
    match config::output()? {
        config::Output::Json => Ok(serde_json::to_value(report)?),
        config::Output::Human => {
            progress(format!(
                "Plan ready: {} transactions; fee {} base units; no broadcast.",
                report.transactions, report.total_fee
            ));
            progress(format!(
                "Saved in {}. Review the content before publishing.",
                args.output.display()
            ));
            detail(
                1,
                format!("Plan: {}; root: {}", report.plan_id, report.root_txid),
            );
            Ok(Value::Null)
        }
    }
}

fn resume(args: ResumeArgs) -> Result<Value, Error> {
    let node = args.node.connect()?;
    let reviewed = workflows::inspect_plan(&args.plan).map_err(boundary)?;
    approve_publication(
        "Publish Git snapshot",
        &reviewed.plan_id,
        reviewed.total_fee,
        args.yes,
    )?;
    let report = workflows::publish(&node, &args.plan, &reviewed.plan_id).map_err(boundary)?;
    let mut value = serde_json::to_value(&report)?;
    value["publication_plan_id"] = json!(report.plan_id);
    value["plan_id"] = json!(reviewed.plan_id);
    if !report.complete {
        print_report(value)?;
        return Err(Error::Missing(format!(
            "Git publication remains incomplete: {}",
            report.blocked_reason
        )));
    }
    Ok(value)
}

fn recover(args: RecoverArgs) -> Result<Value, Error> {
    let node = args.node.connect()?;
    let root = workflows::parse_root(&node, &args.root).map_err(boundary)?;
    Ok(serde_json::to_value(
        workflows::recover(&node, root, &args.output, &args.resources.load()?).map_err(boundary)?,
    )?)
}

fn review(plan: &Path, classifications: &[String]) -> Result<Value, Error> {
    let report = workflows::inspect_plan(plan).map_err(boundary)?;
    let hash = workflows::record_review(plan, classifications).map_err(boundary)?;
    Ok(json!({"plan_id":hash,"review_recorded":true,"broadcast":false,"report":report}))
}

pub(crate) fn run(command: GitCommand) -> Result<Value, Error> {
    match command {
        GitCommand::Prepare(args) => prepare(args),
        GitCommand::Inspect { plan } => Ok(serde_json::to_value(
            workflows::inspect_plan(&plan).map_err(boundary)?,
        )?),
        GitCommand::Review {
            plan,
            classify_public_test_material,
        } => review(&plan, &classify_public_test_material),
        GitCommand::Publish(args) => git_publish_cli::run(args),
        GitCommand::Resume(args) => resume(args),
        GitCommand::Recover(args) => recover(args),
        GitCommand::Clone {
            root,
            directory,
            node,
            resources,
        } => {
            let requested = urma_git::config::CloneDestination(directory);
            urma_git::config::staging_parent(&requested).map_err(boundary)?;
            progress(format!("Connecting to {}...", node.chain()?.label()));
            let node = node.connect()?;
            detail(1, format!("Requested root: {root}"));
            detail(
                3,
                format!(
                    "Observation source: {}; recovery uses bounded parallel batches.",
                    node.inclusion_evidence()
                ),
            );
            progress("Receiving and verifying URMA objects...".into());
            let (report, directory) =
                workflows::clone_root_named(&node, root, requested, &resources.load()?)
                    .map_err(boundary)?;
            progress(format!("Cloning into '{}'...", directory.display()));
            progress(format!(
                "Receiving objects: {} bytes, done.",
                report.snapshot.descriptor.pack_length
            ));
            detail(
                2,
                format!(
                    "Author: {}; Git objects: {}; tree entries: {}; payload SHA256: {}",
                    report.author,
                    report.snapshot.inventory.objects.len(),
                    report.snapshot.inventory.entries.len(),
                    report.snapshot.payload_sha256
                ),
            );
            progress("Verifying signatures, hashes and Git PACK: done.".into());
            progress(format!(
                "Checking out {}: done.",
                hex::encode(&report.snapshot.descriptor.head)
            ));
            progress(format!("Ready in '{}'.", directory.display()));
            Ok(Value::Null)
        }
        GitCommand::Verify {
            snapshot,
            resources,
        } => Ok(serde_json::to_value(
            workflows::verify(&snapshot, &resources.load()?).map_err(boundary)?,
        )?),
    }
}