hyper-jump 0.11.0

A generalist all-in-one command-line version manager toolset for a more efficient development workflow.
use std::path::PathBuf;

use anyhow::Error;
use anyhow::Result;
use serde::Serialize;
use yansi::Paint;

use crate::domain::package::Package;
use crate::domain::version::RemoteVersion;
use crate::domain::version::VersionStatus;
use crate::ports::Fs;
use crate::ports::Output;
use crate::ports::Paths;
use crate::ports::ReleaseProvider;
use crate::ports::UsedVersionStore;
use crate::OutputFormat;

#[derive(Serialize)]
struct RemoteEntry {
    version: String,
    status: String,
}

pub async fn list_remote(
    package: Package,
    fmt: OutputFormat,
    provider: &impl ReleaseProvider,
    output: &impl Output,
    paths: &impl Paths,
    used_store: &impl UsedVersionStore,
    fs: &impl Fs,
) -> Result<(), Error> {
    let versions = provider.list(package.spec()).await?;
    let downloads_dir = paths.downloads_dir(package.clone()).await?;
    let local_versions: Vec<PathBuf> = filter_local_versions(fs, downloads_dir).await?;
    let filtered_versions: Vec<RemoteVersion> = filter_versions(versions)?;

    let padding = " ".repeat(12);
    let mut json_entries = Vec::new();
    let current = used_store.current(package.clone()).await?;
    let current_norm = current.as_deref().map(normalize_tag);
    for version in filtered_versions {
        let version_installed = check_version_installed(&local_versions, &version.tag_name);
        let tag = version.tag_name.clone();

        let tag_norm = normalize_tag(&tag);
        let version_status = match current_norm {
            Some(current) if current == tag_norm => VersionStatus::Used,
            _ if version_installed => VersionStatus::Installed,
            _ => VersionStatus::NotInstalled,
        };

        match fmt {
            OutputFormat::Table => {
                let line = match version_status {
                    VersionStatus::Used => format!("{padding}{}", Paint::green(&tag)),
                    VersionStatus::Installed => {
                        retain_local_versions(local_versions.clone(), &version.tag_name);
                        format!("{padding}{}", Paint::yellow(&tag))
                    }
                    VersionStatus::NotInstalled => format!("{padding}{}", Paint::italic(&tag)),
                };
                output.write_line(&line)?;
            }
            OutputFormat::Json => {
                let status_str = match version_status {
                    VersionStatus::Used => "used",
                    VersionStatus::Installed => "installed",
                    VersionStatus::NotInstalled => "not_installed",
                };
                json_entries.push(RemoteEntry {
                    version: tag.clone(),
                    status: status_str.to_string(),
                });
            }
        }
    }

    if matches!(fmt, OutputFormat::Json) {
        let json = serde_json::to_string_pretty(&json_entries)?;
        output.write_line(&json)?;
    }

    Ok(())
}

fn check_version_installed(local_versions: &[PathBuf], tag: &str) -> bool {
    let tag_norm = normalize_tag(tag);
    local_versions.iter().any(|v| {
        v.file_name()
            .and_then(|str| str.to_str())
            .is_some_and(|str| normalize_tag(str) == tag_norm)
    })
}

fn retain_local_versions(local_versions: Vec<PathBuf>, tag: &str) {
    let mut local_versions = local_versions;
    local_versions.retain(|v| {
        v.file_name().and_then(|str| str.to_str()).is_none_or(|str| !str.contains(tag))
    });
}

fn filter_versions(versions: Vec<RemoteVersion>) -> Result<Vec<RemoteVersion>, Error> {
    Ok(versions.into_iter().filter(|v| !v.prerelease).collect())
}

async fn filter_local_versions(
    fs: &impl Fs,
    downloads_dir: PathBuf,
) -> Result<Vec<PathBuf>, Error> {
    fs.read_dir(&downloads_dir).await
}

fn normalize_tag(tag: &str) -> &str {
    match tag.strip_prefix('v') {
        Some(rest) if rest.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) => rest,
        _ => tag,
    }
}