bashers 0.8.8

Installable cli helpers
Documentation
use crate::utils::{colors::Colors, spinner};
use anyhow::{Context, Result};
use regex::Regex;
use std::env;
use std::path::Path;
use std::process::Command;

const CRATES_IO_CRATE: &str = "bashers";
const PYPI_PACKAGE: &str = "bashers";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallKind {
    Cargo,
    Pip,
}

pub fn detect_install_kind() -> InstallKind {
    if let Ok(exe) = env::current_exe() {
        let path_str = exe.to_string_lossy();
        if path_str.contains(".cargo") {
            return InstallKind::Cargo;
        }
        if path_str.contains(".local") && (path_str.contains("bin") || path_str.contains("Scripts"))
        {
            return InstallKind::Pip;
        }
        if let Ok(venv) = env::var("VIRTUAL_ENV") {
            let venv_path = Path::new(&venv);
            if exe.strip_prefix(venv_path).is_ok() {
                return InstallKind::Pip;
            }
        }
    }
    if cargo_has_bashers() && !pip_has_bashers() {
        return InstallKind::Cargo;
    }
    if pip_has_bashers() {
        return InstallKind::Pip;
    }
    InstallKind::Cargo
}

fn cargo_has_bashers() -> bool {
    Command::new("cargo")
        .args(["install", "--list"])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.lines().any(|l| l.trim().starts_with("bashers ")))
        .unwrap_or(false)
}

fn pip_has_bashers() -> bool {
    let mut cmd = Command::new("pip");
    cmd.args(["show", PYPI_PACKAGE]);
    cmd.output()
        .or_else(|_| {
            let mut uv = Command::new("uv");
            uv.args(["pip", "show", PYPI_PACKAGE]);
            uv.output()
        })
        .ok()
        .filter(|o| o.status.success())
        .is_some()
}

pub fn run() -> Result<()> {
    let kind = detect_install_kind();
    let mut colors = Colors::new();

    let latest_version = if spinner::should_show_spinner() {
        let mut sp = spinner::create_spinner("Checking for updates...");
        let result = match kind {
            InstallKind::Cargo => get_latest_version_crates_io(),
            InstallKind::Pip => get_latest_version_pypi(),
        };
        spinner::stop_spinner(sp.as_mut());
        result?
    } else {
        colors.green()?;
        colors.print("Checking for updates...")?;
        colors.reset()?;
        colors.println("")?;
        match kind {
            InstallKind::Cargo => get_latest_version_crates_io()?,
            InstallKind::Pip => get_latest_version_pypi()?,
        }
    };

    if latest_version == CURRENT_VERSION {
        colors.green()?;
        colors.println(&format!("Already up to date (v{})", CURRENT_VERSION))?;
        colors.reset()?;
        return Ok(());
    }

    colors.green()?;
    colors.print(&format!("New version available: v{}", latest_version))?;
    colors.reset()?;
    colors.println("")?;
    colors.print(&format!("Current version: v{}", CURRENT_VERSION))?;
    colors.println("")?;

    let (install_msg, success) = match kind {
        InstallKind::Cargo => {
            colors.green()?;
            colors.print("Installing via cargo...")?;
            colors.reset()?;
            colors.println("")?;
            let status = Command::new("cargo")
                .args(["install", CRATES_IO_CRATE, "--force"])
                .status()
                .context("Failed to run cargo install")?;
            ("cargo install", status.success())
        }
        InstallKind::Pip => {
            colors.green()?;
            colors.print("Installing via pip...")?;
            colors.reset()?;
            colors.println("")?;
            let status = run_pip_upgrade();
            ("pip install --upgrade", status)
        }
    };

    if !success {
        anyhow::bail!("{} failed", install_msg);
    }

    colors.green()?;
    colors.println(&format!("Successfully updated to v{}", latest_version))?;
    colors.reset()?;

    Ok(())
}

fn run_pip_upgrade() -> bool {
    let pip = Command::new("pip")
        .args(["install", "--upgrade", PYPI_PACKAGE])
        .status();
    if let Ok(s) = pip {
        return s.success();
    }
    Command::new("uv")
        .args(["pip", "install", "--upgrade", PYPI_PACKAGE])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

fn get_latest_version_crates_io() -> Result<String> {
    let output = Command::new("curl")
        .args([
            "-s",
            &format!("https://crates.io/api/v1/crates/{}", CRATES_IO_CRATE),
        ])
        .output()
        .context("Failed to fetch latest version from crates.io")?;

    if !output.status.success() {
        anyhow::bail!("Failed to fetch latest version");
    }

    let response = String::from_utf8(output.stdout)?;
    let re = Regex::new(r#""newest_version"\s*:\s*"([^"]+)""#).context("Failed to create regex")?;

    let version = re
        .captures(&response)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str().to_string())
        .context("No newest_version in crates.io API response")?;

    Ok(version)
}

fn get_latest_version_pypi() -> Result<String> {
    let output = Command::new("curl")
        .args([
            "-s",
            &format!("https://pypi.org/pypi/{}/json", PYPI_PACKAGE),
        ])
        .output()
        .context("Failed to fetch latest version from PyPI")?;

    if !output.status.success() {
        anyhow::bail!("Failed to fetch latest version from PyPI");
    }

    let response = String::from_utf8(output.stdout)?;
    let re = Regex::new(r#""version"\s*:\s*"([^"]+)""#).context("Failed to create regex")?;
    let version = re
        .captures(&response)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str().to_string())
        .context("No version in PyPI API response (package may not exist)")?;
    Ok(version)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_version_from_crates_io_api() {
        let response = r#"{"crate":{"newest_version":"0.4.12"}}"#;
        let re = Regex::new(r#""newest_version"\s*:\s*"([^"]+)""#).unwrap();
        let version = re
            .captures(response)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_string());
        assert_eq!(version, Some("0.4.12".to_string()));
    }

    #[test]
    fn test_parse_version_with_whitespace() {
        let response = r#"{"newest_version" : "0.4.11"}"#;
        let re = Regex::new(r#""newest_version"\s*:\s*"([^"]+)""#).unwrap();
        let version = re
            .captures(response)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_string());
        assert_eq!(version, Some("0.4.11".to_string()));
    }

    #[test]
    fn test_parse_version_invalid_response() {
        let response = r#"{"crate":{"name":"bashers"}}"#;
        let re = Regex::new(r#""newest_version"\s*:\s*"([^"]+)""#).unwrap();
        let version = re
            .captures(response)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_string());
        assert_eq!(version, None);
    }

    #[test]
    fn test_version_constant() {
        assert_eq!(env!("CARGO_PKG_VERSION"), CURRENT_VERSION);
    }

    #[test]
    fn test_parse_version_pypi_response() {
        let response = r#"{"info":{"version":"0.8.5","name":"bashers"}}"#;
        let re = Regex::new(r#""version"\s*:\s*"([^"]+)""#).unwrap();
        let version = re
            .captures(response)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_string());
        assert_eq!(version, Some("0.8.5".to_string()));
    }
}