pandora-kit 0.3.0

Interactive TUI toolkit for the Hefesto framework
use clap::Args;
use std::env;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;

const UPDATE_GUIDE: &str = include_str!("../UPDATE_GUIDE.md");
const RELEASES_API: &str =
    "https://gitlab.com/api/v4/projects/SpicyDogWings%2Fhefesto/releases?per_page=1";
const ASSET_NAME: &str = "pandora";
const CHECKSUM_ASSET: &str = "pandora.sha256";

#[derive(Args)]
pub struct UpdateArgs {
    /// Re-install even when already on the latest version
    #[arg(short, long)]
    pub force: bool,

    /// Show this guide
    #[arg(long)]
    pub guide: bool,
}

pub fn run(args: UpdateArgs) {
    if args.guide {
        println!("{}", UPDATE_GUIDE);
        return;
    }

    let current = env!("CARGO_PKG_VERSION");
    println!("Current version: {current}");

    let body = match fetch(RELEASES_API) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("error: could not fetch releases: {e}");
            std::process::exit(1);
        }
    };

    let releases: serde_json::Value = match serde_json::from_str(&body) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("error: invalid release JSON: {e}");
            std::process::exit(1);
        }
    };

    let release = match releases.as_array().and_then(|a| a.first()) {
        Some(r) => r,
        None => {
            eprintln!("error: no releases found");
            std::process::exit(1);
        }
    };

    let tag = release["tag_name"].as_str().unwrap_or("").trim_start_matches('v');
    if tag.is_empty() {
        eprintln!("error: release has no tag_name");
        std::process::exit(1);
    }
    println!("Latest version:  {tag}");

    if tag == current && !args.force {
        println!("Already up to date.");
        return;
    }

    let links = release["assets"]["links"].as_array();

    let url = match links
        .and_then(|ls| ls.iter().find(|l| l["name"] == ASSET_NAME))
        .and_then(|l| l["direct_asset_url"].as_str())
    {
        Some(u) => u,
        None => {
            eprintln!("error: asset '{ASSET_NAME}' not found in release {tag}");
            std::process::exit(1);
        }
    };

    let sha_url = match links
        .and_then(|ls| ls.iter().find(|l| l["name"] == CHECKSUM_ASSET))
        .and_then(|l| l["direct_asset_url"].as_str())
    {
        Some(u) => u,
        None => {
            eprintln!("error: checksum asset '{CHECKSUM_ASSET}' not found in release {tag}");
            std::process::exit(1);
        }
    };

    let exe_path = match env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("error: could not determine binary path: {e}");
            std::process::exit(1);
        }
    };
    let tmp_path = exe_path.with_extension("new");

    println!("Downloading...");
    let status = Command::new("curl")
        .args(["-fsSL", "-o"])
        .arg(&tmp_path)
        .arg(url)
        .status();

    match status {
        Ok(s) if s.success() => {}
        Ok(s) => {
            eprintln!("error: download failed (curl exit {})", s.code().unwrap_or(-1));
            std::process::exit(1);
        }
        Err(e) => {
            eprintln!("error: could not run curl: {e}");
            std::process::exit(1);
        }
    }

    let expected = match fetch(sha_url) {
        Ok(s) => s.split_whitespace().next().unwrap_or("").to_string(),
        Err(e) => {
            eprintln!("error: could not fetch checksum: {e}");
            let _ = fs::remove_file(&tmp_path);
            std::process::exit(1);
        }
    };

    if expected.is_empty() {
        eprintln!("error: invalid checksum format");
        let _ = fs::remove_file(&tmp_path);
        std::process::exit(1);
    }

    let actual = sha256_digest(&tmp_path).unwrap_or_else(|e| {
        eprintln!("error: could not compute checksum: {e}");
        let _ = fs::remove_file(&tmp_path);
        std::process::exit(1);
    });

    if expected != actual {
        eprintln!("error: checksum mismatch (expected {expected}, got {actual})");
        let _ = fs::remove_file(&tmp_path);
        std::process::exit(1);
    }

    println!("Checksum verified.");

    let mut perms = match fs::metadata(&tmp_path) {
        Ok(m) => m.permissions(),
        Err(e) => {
            eprintln!("error: could not stat downloaded file: {e}");
            let _ = fs::remove_file(&tmp_path);
            std::process::exit(1);
        }
    };
    perms.set_mode(0o755);
    if let Err(e) = fs::set_permissions(&tmp_path, perms) {
        eprintln!("error: could not chmod: {e}");
        let _ = fs::remove_file(&tmp_path);
        std::process::exit(1);
    }

    if let Err(e) = fs::rename(&tmp_path, &exe_path) {
        eprintln!("error: could not replace binary at {}: {e}", exe_path.display());
        eprintln!("hint: run with sudo if installed in a system directory");
        let _ = fs::remove_file(&tmp_path);
        std::process::exit(1);
    }

    println!("Updated to {tag}");
}

fn sha256_digest(path: &std::path::Path) -> Result<String, String> {
    let output = Command::new("sha256sum")
        .arg(path)
        .output()
        .map_err(|e| format!("could not run sha256sum: {e}"))?;

    if !output.status.success() {
        return Err("sha256sum failed".into());
    }

    String::from_utf8(output.stdout)
        .map_err(|e| format!("non-utf8 sha256sum output: {e}"))
        .map(|s| s.split_whitespace().next().unwrap_or("").to_string())
}

fn fetch(url: &str) -> Result<String, String> {
    let output = Command::new("curl")
        .args(["-fsSL", url])
        .output()
        .map_err(|e| format!("could not run curl: {e}"))?;

    if !output.status.success() {
        return Err(format!("curl exited with code {}", output.status.code().unwrap_or(-1)));
    }

    String::from_utf8(output.stdout).map_err(|e| format!("non-utf8 response: {e}"))
}