soar-cli 0.12.1

A modern package manager for Linux
use std::{
    fmt::Display,
    io::Write,
    sync::{LazyLock, RwLock},
};

use nu_ansi_term::Color::{self, Blue, Cyan, Green, LightRed, Magenta, Red};
use serde::Serialize;
use soar_config::{
    config::get_config, display::DisplaySettings, repository::get_platform_repositories,
};
use soar_core::{
    error::{ErrorContext, SoarError},
    package::install::InstallTarget,
    SoarResult,
};
use soar_package::PackageExt;
use soar_utils::{bytes::format_bytes, system::platform};
use tracing::{error, info};

pub struct Icons;

impl Icons {
    pub const ARROW: &str = "";
    pub const BROKEN: &str = "";
    pub const BUILD: &str = "🔨";
    pub const CALENDAR: &str = "📅";
    pub const CHECK: &str = "";
    pub const CHECKSUM: &str = "🔏";
    pub const CROSS: &str = "";
    pub const DESCRIPTION: &str = "📝";
    pub const HOME: &str = "🏠";
    pub const INSTALLED: &str = "";
    pub const LICENSE: &str = "📜";
    pub const LINK: &str = "🔗";
    pub const LOG: &str = "📄";
    pub const MAINTAINER: &str = "👤";
    pub const NOTE: &str = "📌";
    pub const NOT_INSTALLED: &str = "";
    pub const PACKAGE: &str = "📦";
    pub const SCRIPT: &str = "📃";
    pub const SIZE: &str = "💾";
    pub const TYPE: &str = "📁";
    pub const VERSION: &str = "🏁";
    pub const WARNING: &str = "";
}

pub fn icon_or<'a>(icon: &'a str, fallback: &'a str) -> &'a str {
    if get_config().display().icons() {
        icon
    } else {
        fallback
    }
}

pub fn display_settings() -> DisplaySettings {
    get_config().display()
}

pub fn term_width() -> usize {
    terminal_size::terminal_size()
        .map(|(w, _)| w.0 as usize)
        .unwrap_or(80)
}

pub static COLOR: LazyLock<RwLock<bool>> = LazyLock::new(|| RwLock::new(true));
pub static PROGRESS: LazyLock<RwLock<bool>> = LazyLock::new(|| RwLock::new(true));

pub fn progress_enabled() -> bool {
    *PROGRESS.read().unwrap()
}

pub fn interactive_ask(ques: &str) -> SoarResult<String> {
    print!("{ques}");

    std::io::stdout()
        .flush()
        .with_context(|| "flushing stdout stream".to_string())?;

    let mut response = String::new();
    std::io::stdin()
        .read_line(&mut response)
        .with_context(|| "reading input from stdin".to_string())?;

    Ok(response.trim().to_owned())
}

pub struct Colored<T: Display>(pub Color, pub T);

impl<T: Display> Display for Colored<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let color = COLOR.read().unwrap();
        if *color {
            write!(f, "{}", self.0.prefix())?;
            self.1.fmt(f)?;
            write!(f, "{}", self.0.suffix())
        } else {
            self.1.fmt(f)
        }
    }
}

pub fn vec_string<T: Display + Serialize>(value: Option<Vec<T>>) -> Option<String> {
    value.and_then(|json| serde_json::to_string(&json).ok())
}

pub fn get_valid_selection(max: usize) -> SoarResult<usize> {
    loop {
        let response = interactive_ask("Select a package: ")?;
        match response.parse::<usize>() {
            Ok(n) if n > 0 && n <= max => return Ok(n - 1),
            _ => error!("Invalid selection, please try again."),
        }
    }
}

pub fn confirm_action(message: &str) -> SoarResult<bool> {
    let response = interactive_ask(&format!("{} [y/N]: ", message))?;
    Ok(matches!(response.to_lowercase().as_str(), "y" | "yes"))
}

pub fn select_package_interactively<T: PackageExt>(
    pkgs: Vec<T>,
    package_name: &str,
) -> SoarResult<Option<T>> {
    select_package_interactively_with_installed(pkgs, package_name, &[])
}

pub fn select_package_interactively_with_installed<T: PackageExt>(
    pkgs: Vec<T>,
    package_name: &str,
    installed: &[(String, String, String)], // (pkg_id, repo_name, version)
) -> SoarResult<Option<T>> {
    info!("Showing available packages for {package_name}");
    for (idx, pkg) in pkgs.iter().enumerate() {
        let is_installed = installed.iter().any(|(pkg_id, repo_name, _version)| {
            pkg.pkg_id() == pkg_id && pkg.repo_name() == repo_name
        });
        let installed_marker = if is_installed {
            format!(" {}", Colored(Color::Yellow, "[installed]"))
        } else {
            String::new()
        };
        info!(
            "[{}] {}#{}:{} | {}{}",
            idx + 1,
            Colored(Blue, &pkg.pkg_name()),
            Colored(Cyan, &pkg.pkg_id()),
            Colored(Green, pkg.repo_name()),
            Colored(LightRed, pkg.version()),
            installed_marker
        );
    }

    let selection = get_valid_selection(pkgs.len())?;
    Ok(pkgs.into_iter().nth(selection))
}

pub fn pretty_package_size(ghcr_size: Option<u64>, size: Option<u64>) -> String {
    ghcr_size
        .map(|size| format!("{}", Colored(Magenta, format_bytes(size, 2))))
        .or_else(|| size.map(|size| format!("{}", Colored(Magenta, format_bytes(size, 2)))))
        .unwrap_or_default()
}

pub fn ask_target_action(targets: &[InstallTarget], action: &str) -> SoarResult<()> {
    info!(
        "\n{}\n",
        Colored(
            Green,
            format!(
                "These are the packages that would be {}:",
                if action == "install" {
                    "installed"
                } else {
                    "updated"
                }
            )
        )
    );
    for target in targets {
        info!(
            "{}#{}:{} ({})",
            Colored(Blue, &target.package.pkg_name),
            Colored(Cyan, &target.package.pkg_id),
            Colored(Green, &target.package.repo_name),
            Colored(LightRed, &target.package.version)
        )
    }

    info!(
        "Total: {} packages. Estimated download size: {}\n",
        targets.len(),
        format_bytes(
            targets.iter().fold(0, |acc, target| {
                acc + target
                    .package
                    .ghcr_size
                    .or(target.package.size)
                    .unwrap_or_default()
            }),
            2
        )
    );
    let response = interactive_ask(&format!(
        "Would you like to {} these packages? [{}/{}] ",
        action,
        Colored(Green, "Yes"),
        Colored(Red, "No")
    ))?
    .to_lowercase();
    let response = response.trim();

    if !response.is_empty() && response != "y" {
        info!("Quitting");
        std::process::exit(0);
    }

    Ok(())
}

pub fn parse_default_repos_arg(arg: &str) -> SoarResult<String> {
    let repo = arg.trim().to_lowercase();
    let supported_repos: Vec<&str> = get_platform_repositories()
        .into_iter()
        .filter(|repo| repo.platforms.contains(&platform().as_str()))
        .map(|repo| repo.name)
        .collect();

    if supported_repos.contains(&repo.as_str()) {
        Ok(repo)
    } else {
        Err(SoarError::Custom(format!(
            "Invalid repository '{}'. Valid options for this platform ({}) are: {}",
            repo,
            platform(),
            supported_repos.join(", ")
        )))
    }
}