zinc-wallet-cli 0.4.0

Agent-first Bitcoin + Ordinals CLI wallet with account-based taproot ordinals + native segwit payment addresses (optional human mode)
use crate::config::{NetworkArg, PaymentAddressTypeArg, Profile, SchemeArg};
use crate::error::AppError;
use std::env;
use std::path::{Path, PathBuf};

pub fn home_dir() -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        PathBuf::from(home)
    } else {
        PathBuf::from(".")
    }
}

pub fn env_non_empty(name: &str) -> Option<String> {
    let value = env::var(name).ok()?;
    let trimmed = value.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

pub fn env_bool(name: &str) -> Option<bool> {
    let value = env_non_empty(name)?;
    let normalized = value.to_ascii_lowercase();
    match normalized.as_str() {
        "1" | "true" | "yes" | "on" => Some(true),
        "0" | "false" | "no" | "off" => Some(false),
        _ => None,
    }
}

pub fn parse_network(s: &str) -> Result<NetworkArg, AppError> {
    match s.to_lowercase().as_str() {
        "bitcoin" | "mainnet" => Ok(NetworkArg::Bitcoin),
        "signet" => Ok(NetworkArg::Signet),
        "testnet" => Ok(NetworkArg::Testnet),
        "regtest" => Ok(NetworkArg::Regtest),
        _ => Err(AppError::Invalid(format!("unknown network: {s}"))),
    }
}

pub fn parse_scheme(s: &str) -> Result<SchemeArg, AppError> {
    match s.to_lowercase().as_str() {
        "unified" => Ok(SchemeArg::Unified),
        "dual" => Ok(SchemeArg::Dual),
        _ => Err(AppError::Invalid(format!("unknown scheme: {s}"))),
    }
}

pub fn parse_payment_address_type(s: &str) -> Result<PaymentAddressTypeArg, AppError> {
    match s.trim().to_ascii_lowercase().as_str() {
        "native" => Ok(PaymentAddressTypeArg::Native),
        "nested" => Ok(PaymentAddressTypeArg::Nested),
        "legacy" => Ok(PaymentAddressTypeArg::Legacy),
        _ => Err(AppError::Invalid(format!(
            "unknown payment address type: {s}"
        ))),
    }
}

pub(crate) fn parse_bool_value(value: &str, context: &str) -> Result<bool, String> {
    let normalized = value.trim().to_ascii_lowercase();
    match normalized.as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        _ => Err(format!(
            "{context} must be one of: true,false,yes,no,on,off,1,0"
        )),
    }
}

pub(crate) fn unknown_with_hint(kind: &str, unknown: &str, candidates: &[&str]) -> String {
    if let Some(suggestion) = best_match(unknown, candidates) {
        return format!("unknown {kind}: {unknown} (did you mean {suggestion}?)");
    }
    format!("unknown {kind}: {unknown}")
}

pub(crate) fn best_match<'a>(needle: &str, candidates: &'a [&'a str]) -> Option<&'a str> {
    let mut best: Option<(&str, usize)> = None;
    for &candidate in candidates {
        let score = levenshtein(needle, candidate);
        match best {
            Some((_, best_score)) if score >= best_score => {}
            _ => best = Some((candidate, score)),
        }
    }

    let (candidate, score) = best?;
    let threshold = match needle.len() {
        0..=4 => 1,
        5..=9 => 2,
        _ => 3,
    };

    if score <= threshold {
        Some(candidate)
    } else {
        None
    }
}

pub fn maybe_write_text(path: Option<&str>, text: &str) -> Result<(), crate::error::AppError> {
    if let Some(path) = path {
        crate::paths::write_secure_file(path, text.as_bytes())
            .map_err(|e| crate::error::AppError::Io(format!("failed to write to {path}: {e}")))
    } else {
        Ok(())
    }
}

pub fn run_bitcoin_cli(
    profile: &Profile,
    args: &[String],
) -> Result<String, crate::error::AppError> {
    let mut cmd = std::process::Command::new(&profile.bitcoin_cli);
    for arg in &profile.bitcoin_cli_args {
        cmd.arg(arg);
    }
    cmd.args(args);
    let output = cmd
        .output()
        .map_err(|e| crate::error::AppError::Internal(format!("bitcoin-cli failed: {e}")))?;
    if !output.status.success() {
        let err = String::from_utf8_lossy(&output.stderr);
        return Err(crate::error::AppError::Internal(format!(
            "bitcoin-cli error: {err}"
        )));
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
    if a == b {
        return 0;
    }
    if a.is_empty() {
        return b.chars().count();
    }
    if b.is_empty() {
        return a.chars().count();
    }

    let b_len = b.chars().count();
    let mut prev: Vec<usize> = (0..=b_len).collect();
    let mut curr = vec![0; b_len + 1];

    for (i, ca) in a.chars().enumerate() {
        curr[0] = i + 1;
        for (j, cb) in b.chars().enumerate() {
            let cost = usize::from(ca != cb);
            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
        }
        std::mem::swap(&mut prev, &mut curr);
    }

    prev[b_len]
}
pub fn resolve_psbt_source(
    psbt: Option<&str>,
    psbt_file: Option<&Path>,
    psbt_stdin: bool,
) -> Result<String, AppError> {
    let count = u8::from(psbt.is_some()) + u8::from(psbt_file.is_some()) + u8::from(psbt_stdin);
    if count > 1 {
        return Err(AppError::Invalid(
            "accepts only one of --psbt, --psbt-file, --psbt-stdin".to_string(),
        ));
    }
    if let Some(psbt) = psbt {
        return Ok(psbt.to_string());
    }
    if let Some(path) = psbt_file {
        return std::fs::read_to_string(path).map_err(|e| {
            AppError::Io(format!("failed to read psbt file {}: {e}", path.display()))
        });
    }
    if psbt_stdin {
        use std::io::Read;
        let mut buffer = String::new();
        std::io::stdin()
            .read_to_string(&mut buffer)
            .map_err(|e| AppError::Io(format!("failed to read psbt from stdin: {e}")))?;
        let trimmed = buffer.trim();
        if trimmed.is_empty() {
            return Err(AppError::Invalid(
                "stdin did not contain a PSBT string".to_string(),
            ));
        }
        return Ok(trimmed.to_string());
    }
    Err(AppError::Invalid(
        "requires one of --psbt, --psbt-file, --psbt-stdin".to_string(),
    ))
}

pub fn parse_indices(s: Option<&str>) -> Result<Vec<usize>, AppError> {
    let s = match s {
        Some(s) => s,
        None => return Ok(Vec::new()),
    };
    let mut indices = Vec::new();
    for part in s.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        if part.contains('-') {
            let bounds: Vec<&str> = part.split('-').collect();
            if bounds.len() != 2 {
                return Err(AppError::Invalid(format!("invalid index range: {part}")));
            }
            let start: usize = bounds[0]
                .parse()
                .map_err(|_| AppError::Invalid(format!("invalid start index: {}", bounds[0])))?;
            let end: usize = bounds[1]
                .parse()
                .map_err(|_| AppError::Invalid(format!("invalid end index: {}", bounds[1])))?;
            for i in start..=end {
                indices.push(i);
            }
        } else {
            let index: usize = part
                .parse()
                .map_err(|_| AppError::Invalid(format!("invalid index: {part}")))?;
            indices.push(index);
        }
    }
    Ok(indices)
}