soar-cli 0.13.0

A modern package manager for Linux
use std::{
    env::{self, consts::ARCH},
    fs,
    io::{self, Write},
};

use semver::Version;
use soar_core::{
    error::{ErrorContext, SoarError},
    SoarResult,
};
use soar_dl::{
    download::Download,
    github::Github,
    http_client::SHARED_AGENT,
    traits::{Asset as _, Platform as _, Release as _},
    types::OverwriteMode,
};
use soar_utils::bytes::format_bytes;
use tracing::{debug, error, info, warn};

use crate::{
    cli::SelfAction,
    progress::{create_download_job, handle_download_progress},
};

pub async fn process_self_action(action: &SelfAction) -> SoarResult<()> {
    let self_bin =
        env::current_exe().with_context(|| "Failed to get executable path".to_string())?;
    let self_version = env!("CARGO_PKG_VERSION");

    debug!("Executable path: {}", self_bin.display());

    match action {
        SelfAction::Update {
            yes,
        } => {
            let is_nightly = self_version.starts_with("nightly");
            debug!("Current version: {}", self_version);

            let target_nightly = match (env::var("SOAR_NIGHTLY"), env::var("SOAR_RELEASE")) {
                (Ok(_), Err(_)) => true,
                (Err(_), Ok(_)) => false,
                _ => is_nightly,
            };

            let releases = Github::fetch_releases("pkgforge/soar", None)?;

            let release = releases.iter().find(|release| {
                let is_nightly_release = release.tag().starts_with("nightly");
                debug!(
                    "Checking release: {}, Release Channel: {}",
                    release.tag(),
                    if is_nightly_release {
                        "nightly"
                    } else {
                        "stable"
                    }
                );

                if target_nightly {
                    is_nightly_release && release.name() != self_version
                } else {
                    let release_version = release.tag().trim_start_matches("v");
                    let parsed_release_version = Version::parse(release_version).ok();
                    let parsed_self_version = Version::parse(self_version).ok();

                    match (parsed_release_version, parsed_self_version) {
                        (Some(release_ver), Some(self_ver)) => {
                            let should_update = !is_nightly_release && release_ver > self_ver;
                            debug!(
                                "Comparing versions: release_ver={}, self_ver={}, should_update={}",
                                release_ver, self_ver, should_update
                            );
                            should_update
                        }
                        (_, None) => is_nightly,
                        _ => {
                            debug!(
                                "Skipping release {} due to invalid version.",
                                release_version
                            );
                            false
                        }
                    }
                }
            });

            if let Some(release) = release {
                let new_version = release.tag();

                if let Some(body) = release.body() {
                    if !body.is_empty() {
                        println!("\nRelease Notes for {}:\n", new_version);
                        println!("{}", body);
                        println!();
                    } else {
                        println!("No release notes available for this update.");
                    }
                } else {
                    println!("No release notes available for this update.");
                }

                if !yes {
                    print!("Update to {}? [y/N] ", new_version);
                    io::stdout().flush().map_err(|e| {
                        SoarError::IoError {
                            action: "flushing stdout".to_string(),
                            source: e,
                        }
                    })?;

                    let mut input = String::new();
                    io::stdin().read_line(&mut input).map_err(|e| {
                        SoarError::IoError {
                            action: "reading user input".to_string(),
                            source: e,
                        }
                    })?;

                    if !input.trim().to_lowercase().starts_with('y') {
                        info!("Update cancelled.");
                        return Ok(());
                    }
                }

                if target_nightly != is_nightly {
                    info!(
                        "Switching from {} to {} channel",
                        if is_nightly { "nightly" } else { "stable" },
                        if target_nightly { "nightly" } else { "stable" }
                    );
                } else {
                    info!("Found new update: {}", release.tag());
                }
                let assets = release.assets();
                let asset = assets
                    .iter()
                    .find(|a| {
                        a.name.contains(ARCH) && !a.name.contains("tar") && !a.name.contains("sum")
                    })
                    .ok_or_else(|| {
                        SoarError::Custom(format!("No matching asset found for {}", ARCH))
                    })?;

                debug!("Selected asset: {}", asset.name());

                if let Some(size) = asset.size() {
                    info!("Download size: {}", format_bytes(size, 2));
                }

                let pb = create_download_job("Downloading");

                // Stage the download next to the target and swap it in atomically,
                // so an interrupted or corrupt update never replaces the running
                // binary with a truncated file.
                let staging = {
                    let mut path = self_bin.clone();
                    let mut name = path.file_name().unwrap_or_default().to_os_string();
                    name.push(".update.part");
                    path.set_file_name(name);
                    path
                };

                let checksum_asset = assets
                    .iter()
                    .find(|a| a.name() == format!("{}.b3sum", asset.name()));
                let expected_checksum = match checksum_asset {
                    Some(a) => {
                        Some(fetch_expected_checksum(a.url()).ok_or_else(|| {
                            SoarError::Custom(format!(
                                "Failed to fetch published checksum {}; aborting update",
                                a.name()
                            ))
                        })?)
                    }
                    None => {
                        warn!(
                            "No checksum published for {}; updating without integrity verification",
                            asset.name()
                        );
                        None
                    }
                };

                let mut dl = Download::new(asset.url())
                    .output(staging.to_string_lossy())
                    .overwrite(OverwriteMode::Force)
                    .progress({
                        let pb = pb.clone();
                        move |p| handle_download_progress(p, &pb)
                    });
                if let Some(ref checksum) = expected_checksum {
                    dl = dl.checksum(checksum.clone());
                }

                debug!("Downloading update from: {}", asset.url());
                match dl.execute() {
                    Ok(_) => {
                        fs::rename(&staging, &self_bin).with_context(|| {
                            format!("replacing {} with the updated binary", self_bin.display())
                        })?;
                        info!("Soar updated to {}", release.tag());
                    }
                    Err(err) => {
                        let _ = fs::remove_file(&staging);
                        return Err(err.into());
                    }
                }
            } else {
                eprintln!("No updates found.");
            }
        }
        SelfAction::Uninstall => {
            match fs::remove_file(self_bin) {
                Ok(_) => {
                    info!("Soar has been uninstalled successfully.");
                    info!("You should remove soar config and data files manually.");
                }
                Err(err) => {
                    error!("{}\nFailed to uninstall soar.", err.to_string());
                }
            };
        }
    };

    Ok(())
}

/// Fetches a published `.b3sum` asset and extracts the BLAKE3 hex digest.
///
/// The file follows the `b3sum` convention of `<hex>  <filename>`, so only the
/// leading token is taken. Returns `None` when the asset cannot be fetched or is
/// empty, leaving the update to proceed without checksum verification.
fn fetch_expected_checksum(url: &str) -> Option<String> {
    let resp = SHARED_AGENT.get(url).call().ok()?;
    if !resp.status().is_success() {
        return None;
    }
    let body = resp.into_body().read_to_string().ok()?;
    body.split_whitespace().next().map(str::to_string)
}