urma-cli 0.1.1

Command-line tools for URMA identities, wallets, Wire, archives and Git
use serde::Deserialize;
use std::path::PathBuf;
use urma_chain::observation::Chain;
use urma_runtime::error::{Context, Error, ensure};
use urma_runtime::node::NodeConfig;

#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct Settings {
    pub(crate) log_level: LogLevel,
    pub(crate) rpc_url: Option<String>,
    pub(crate) node_auth_file: Option<PathBuf>,
    pub(crate) vault: Option<PathBuf>,
    pub(crate) unlock_file: Option<PathBuf>,
}

#[derive(Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum LogLevel {
    #[default]
    Info,
    Warn,
    Debug,
}

impl LogLevel {
    pub(crate) fn filter(self) -> tracing_subscriber::filter::LevelFilter {
        use tracing_subscriber::filter::LevelFilter;
        match self {
            Self::Info => LevelFilter::INFO,
            Self::Warn => LevelFilter::WARN,
            Self::Debug => LevelFilter::DEBUG,
        }
    }
}

pub(crate) fn log_directory() -> Result<PathBuf, Error> {
    let state = match std::env::var_os("XDG_STATE_HOME") {
        Some(value) if PathBuf::from(&value).is_absolute() => PathBuf::from(value),
        Some(value) => {
            tracing::warn!(path = ?value, "ignoring relative XDG_STATE_HOME");
            home()?.join(".local/state")
        }
        None => home()?.join(".local/state"),
    };
    Ok(state.join("urma/logs"))
}

pub(crate) fn home() -> Result<PathBuf, Error> {
    Ok(std::env::var_os("HOME")
        .context("HOME is unset; set HOME to your user directory")?
        .into())
}

pub(crate) fn load() -> Result<Settings, Error> {
    let path = match std::env::var_os("URMA_CONFIG") {
        Some(path) => PathBuf::from(path),
        None => home()?.join(".config/urma/config.json"),
    };
    if !path.try_exists()? {
        return Ok(Settings::default());
    }
    Ok(serde_json::from_slice(
        &urma_runtime::storage::read_bounded(&path, 16_384)?,
    )?)
}

pub(crate) fn chain(testnet: bool) -> Result<Chain, Error> {
    if testnet {
        return Ok(Chain::LitecoinTestnet);
    }
    let name = match std::env::var_os("URMA_NETWORK") {
        Some(name) => name,
        None => return Ok(Chain::LitecoinMainnet),
    };
    match name.to_str().context("URMA_NETWORK must be UTF-8")? {
        "litecoin-mainnet" => Ok(Chain::LitecoinMainnet),
        "litecoin-testnet" => Ok(Chain::LitecoinTestnet),
        "bitcoin-regtest" => Ok(Chain::BitcoinRegtest),
        "bitcoin-testnet4" => Ok(Chain::BitcoinTestnet4),
        other => Err(Error::Invalid(format!("invalid URMA_NETWORK {other}"))),
    }
}

pub(crate) fn connection(chain: Chain) -> Result<Connection, Error> {
    let settings = load()?;
    let rpc = select(
        std::env::var_os("URMA_RPC_URL")
            .map(|value| {
                value
                    .into_string()
                    .map_err(|invalid| Error::Invalid(format!("non-UTF-8 RPC URL {invalid:?}")))
            })
            .transpose()?,
        settings.rpc_url,
    );
    let auth = select(
        std::env::var_os("URMA_NODE_AUTH_FILE").map(PathBuf::from),
        settings.node_auth_file,
    );
    match (rpc, auth) {
        (Some(rpc_url), Some(cookie_file)) => Ok(Connection::Local(NodeConfig {
            chain,
            rpc_url,
            cookie_file,
        })),
        (None, None) => Ok(Connection::Public),
        (Some(rpc), None) => Err(Error::Invalid(format!(
            "local node {rpc} needs node_auth_file in config"
        ))),
        (None, Some(auth)) => Err(Error::Invalid(format!(
            "local auth {} needs rpc_url in config",
            auth.display()
        ))),
    }
}

pub(crate) fn credentials() -> Result<(PathBuf, PathBuf), Error> {
    let settings = load()?;
    let vault = path_setting("URMA_VAULT", settings.vault, ".local/share/urma/vault.urma")?;
    let unlock = path_setting(
        "URMA_UNLOCK_FILE",
        settings.unlock_file,
        ".config/urma/unlock",
    )?;
    ensure!(
        vault.try_exists()?,
        "no identity vault; run urma key create --help"
    );
    ensure!(
        unlock.try_exists()?,
        "identity is locked; configure unlock_file in ~/.config/urma/config.json or set URMA_UNLOCK_FILE"
    );
    Ok((vault, unlock))
}

pub(crate) enum Connection {
    Local(NodeConfig),
    Public,
}

fn select<T>(first: Option<T>, second: Option<T>) -> Option<T> {
    match first {
        Some(value) => Some(value),
        None => second,
    }
}

fn path_setting(name: &str, setting: Option<PathBuf>, default: &str) -> Result<PathBuf, Error> {
    match select(std::env::var_os(name).map(PathBuf::from), setting) {
        Some(path) => Ok(path),
        None => Ok(home()?.join(default)),
    }
}

pub(crate) fn git_limits() -> Result<urma_git::inventory::Limits, Error> {
    match std::env::var_os("URMA_GIT_LIMITS") {
        Some(path) => Ok(serde_json::from_slice(
            &urma_runtime::storage::read_bounded(&PathBuf::from(path), 4096)?,
        )?),
        None => Ok(urma_git::inventory::Limits::default()),
    }
}

pub(crate) fn expert_node() -> Result<NodeConfig, Error> {
    match connection(chain(false)?)? {
        Connection::Local(node) => Ok(node),
        Connection::Public => Err(Error::Missing(
            "this expert command needs a local node configured with rpc_url and node_auth_file"
                .into(),
        )),
    }
}

pub(crate) enum Output {
    Human,
    Json,
}

pub(crate) fn output() -> Result<Output, Error> {
    match std::env::var_os("URMA_OUTPUT") {
        None => Ok(Output::Human),
        Some(value) => match value.to_str().context("URMA_OUTPUT must be UTF-8")? {
            "json" => Ok(Output::Json),
            "human" => Ok(Output::Human),
            other => Err(Error::Invalid(format!(
                "unsupported URMA_OUTPUT {other}; use human or json"
            ))),
        },
    }
}

pub(crate) fn archive_key() -> Result<PathBuf, Error> {
    path_setting("URMA_ARCHIVE_KEY", None, ".local/share/urma/archive.key")
}

pub(crate) struct KeyLocation(pub(crate) Option<PathBuf>);

pub(crate) fn key_location(location: KeyLocation) -> Result<PathBuf, Error> {
    match location.0 {
        Some(path) => Ok(path),
        None => archive_key(),
    }
}

pub(crate) fn require_archive_key() -> Result<PathBuf, Error> {
    let path = archive_key()?;
    ensure!(
        path.try_exists()?,
        "no private recovery key at {}; run urma key recovery-generate, or configure URMA_ARCHIVE_KEY",
        path.display()
    );
    Ok(path)
}

static VERBOSITY: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);

pub(crate) fn set_verbosity(level: u8) {
    VERBOSITY.store(level, std::sync::atomic::Ordering::Relaxed);
}

pub(crate) fn verbosity() -> u8 {
    VERBOSITY.load(std::sync::atomic::Ordering::Relaxed)
}

pub(crate) struct PublicationOutput(pub Option<PathBuf>);

pub(crate) fn publication_directory(
    repo: &std::path::Path,
    requested: PublicationOutput,
) -> Result<PathBuf, Error> {
    match requested.0 {
        Some(path) => Ok(path),
        None => Ok(repo.join(".urma-plan")),
    }
}

pub(crate) fn publication_poll_interval() -> std::time::Duration {
    std::time::Duration::from_secs(30)
}

pub(crate) fn publication_tip_interval() -> std::time::Duration {
    std::time::Duration::from_secs(2)
}

pub(crate) struct FeeCeiling(pub Option<u64>);

pub(crate) fn publication_fee_ceiling(requested: FeeCeiling, estimate: u64) -> u64 {
    match requested.0 {
        Some(limit) => limit,
        None => estimate,
    }
}