rlyx 0.3.1

rlyx is a fast release manager that automatically bumps versions, creates changelogs, tags commits, and publishes GitHub releases across JS, Rust, and Python projects with first class monorepos support.
Documentation
use crate::{ui, util};
use anyhow::{anyhow, Result};
use serde_json::Value;
use tokio::task;

const CRATE_NAME: &str = "rlyx";

pub async fn run(owner_repo: &str) -> Result<()> {
    ui::section("self-update");

    let cur = env!("CARGO_PKG_VERSION").to_string();
    let is_cur_prerelease = cur.contains('-');

    let gh_ok = util::run_status("gh", &["--version"]);
    let releases = if gh_ok {
        fetch_releases(owner_repo).unwrap_or_default()
    } else {
        vec![]
    };

    let latest_stable =
        releases.iter().find(|r| !r.prerelease && !r.draft);
    let latest_pre =
        releases.iter().find(|r| r.prerelease && !r.draft);

    match (latest_stable, latest_pre) {
        (None, _) if !gh_ok => {
            ui::warn("gh not found. installing latest stable from crates.io");
            install_from_crates(None).await?;
            ui::ok("updated to latest stable");
            Ok(())
        }
        (None, _) => Err(anyhow!("no releases found")),
        (Some(stable), pre) => {
            let stable_ver = trim_v(&stable.tag);

            if is_cur_prerelease {
                if let Some(pre) = pre {
                    let pre_ver = trim_v(&pre.tag);
                    ui::summary_block(&[
                        ("Current", &cur),
                        ("Stable", stable_ver),
                        ("Latest pre", pre_ver),
                    ]);
                    if ui::confirm(&format!(
                        "install latest prerelease {}?",
                        pre_ver
                    )) {
                        install_from_crates(Some(pre_ver)).await?;
                        ui::ok(&format!("updated to {}", pre_ver));
                        return Ok(());
                    }
                }
                ui::summary_block(&[
                    ("Current", &cur),
                    ("Stable", stable_ver),
                ]);
                if ui::confirm(&format!(
                    "install stable {}?",
                    stable_ver
                )) {
                    install_from_crates(Some(stable_ver)).await?;
                    ui::ok(&format!("updated to {}", stable_ver));
                    return Ok(());
                }
                ui::warn("no changes made");
                Ok(())
            } else if normalize(&cur) == normalize(stable_ver) {
                ui::ok("already up to date");
                Ok(())
            } else {
                ui::summary_block(&[
                    ("Current", &cur),
                    ("Stable", stable_ver),
                ]);
                if ui::confirm(&format!(
                    "install stable {}?",
                    stable_ver
                )) {
                    install_from_crates(Some(stable_ver)).await?;
                    ui::ok(&format!("updated to {}", stable_ver));
                    return Ok(());
                }
                ui::warn("no changes made");
                Ok(())
            }
        }
    }
}

struct Rel {
    tag: String,
    prerelease: bool,
    draft: bool,
}

fn fetch_releases(owner_repo: &str) -> Result<Vec<Rel>> {
    let path = format!("repos/{}/releases?per_page=50", owner_repo);
    let out = crate::util::run_capture("gh", &["api", &path])?;
    let v: Value = serde_json::from_str(&out)?;
    let arr =
        v.as_array().ok_or_else(|| anyhow!("bad releases json"))?;
    let mut outv = Vec::with_capacity(arr.len());
    for r in arr {
        let tag = r
            .get("tag_name")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string();
        if tag.is_empty() {
            continue;
        }
        let prerelease = r
            .get("prerelease")
            .and_then(|x| x.as_bool())
            .unwrap_or(false);
        let draft =
            r.get("draft").and_then(|x| x.as_bool()).unwrap_or(false);
        outv.push(Rel {
            tag,
            prerelease,
            draft,
        });
    }
    Ok(outv)
}

fn trim_v(tag: &str) -> &str {
    tag.strip_prefix('v').unwrap_or(tag)
}

fn normalize(v: &str) -> String {
    v.split('-').next().unwrap_or(v).to_string()
}

async fn install_from_crates(ver_opt: Option<&str>) -> Result<()> {
    let mut args: Vec<String> = vec![
        "install".into(),
        "--force".into(),
        "--locked".into(),
        CRATE_NAME.into(),
    ];
    if let Some(v) = ver_opt {
        args.push("--version".into());
        args.push(v.to_string());
    }

    let pb = crate::ui::make_spinner(
        &crate::ui::mp(),
        "[self-update]",
        "installing from crates.io",
    );
    task::spawn_blocking(move || {
        let arg_refs: Vec<&str> =
            args.iter().map(|s| s.as_str()).collect();
        util::run_quiet("cargo", &arg_refs, "cargo install")
    })
    .await??;
    pb.finish_with_message(crate::ui::done_style("done"));
    Ok(())
}