portablemc-cli 5.0.3

Cross platform command line utility for launching Minecraft quickly and reliably with included support for Mojang versions and popular mod loaders.
//! Implementation of the 'search' command.

use std::process::ExitCode;
use std::fs;

use chrono::{DateTime, Local, TimeDelta, Utc};

use portablemc::base::VersionChannel;
use portablemc::{moj, fabric, forge};

use crate::parse::{SearchArgs, SearchKind, SearchChannel, SearchLatestChannel};
use crate::format::{TimeDeltaFmt, DATE_FORMAT};

use super::{Cli, LogHandler, log_mojang_error, log_forge_error, log_any_error, log_fabric_error};


pub fn search(cli: &mut Cli, args: &SearchArgs) -> ExitCode {
    
    match args.kind {
        SearchKind::Mojang => search_mojang(cli, args),
        SearchKind::Local => search_local(cli, args),
        SearchKind::Fabric => search_fabric(cli, args, fabric::Loader::Fabric, false),
        SearchKind::FabricGame => search_fabric(cli, args, fabric::Loader::Fabric, true),
        SearchKind::Quilt => search_fabric(cli, args, fabric::Loader::Quilt, false),
        SearchKind::QuiltGame => search_fabric(cli, args, fabric::Loader::Quilt, true),
        SearchKind::Legacyfabric => search_fabric(cli, args, fabric::Loader::LegacyFabric, false),
        SearchKind::LegacyfabricGame => search_fabric(cli, args, fabric::Loader::LegacyFabric, true),
        SearchKind::Babric => search_fabric(cli, args, fabric::Loader::Babric, false),
        SearchKind::BabricGame => search_fabric(cli, args, fabric::Loader::Babric, true),
        SearchKind::Forge => search_forge(cli, args, forge::Loader::Forge),
        SearchKind::NeoForge => search_forge(cli, args, forge::Loader::NeoForge),
    }

}

fn search_mojang(cli: &mut Cli, args: &SearchArgs) -> ExitCode {
    
    use moj::Manifest;

    // Initial requests...
    let mut handler = LogHandler::new(&mut cli.out);
    let manifest = match Manifest::request(&mut handler) {
        Ok(manifest) => manifest,
        Err(e) => {
            log_mojang_error(cli, &e);
            return ExitCode::FAILURE;
        }
    };

    let today = Utc::now();

    // Now we construct the table...
    let mut table = cli.out.table(3);

    {
        let mut row = table.row();
        row.cell("name").format("Name");
        row.cell("channel").format("Channel");
        row.cell("release_date").format("Release date");
    }
    
    table.sep();

    // This is an exclusive argument.
    let only_name = args.latest.as_ref().map(|channel| {
        match channel {
            SearchLatestChannel::Release => manifest.latest_release_name(),
            SearchLatestChannel::Snapshot => manifest.latest_snapshot_name(),
        }
    });

    // Finally displaying version(s).
    for version in manifest.iter().take(args.limit) {

        if let Some(only_name) = only_name {
            if version.name() != only_name {
                continue;
            }
        } else {

            if !args.match_filter(version.name()) {
                continue;
            }

            if !args.match_channel(match version.channel() {
                VersionChannel::Release => SearchChannel::Release,
                VersionChannel::Snapshot => SearchChannel::Snapshot,
                VersionChannel::Beta => SearchChannel::Beta,
                VersionChannel::Alpha => SearchChannel::Alpha,
            }) {
                continue;
            }

        }
        
        let mut row = table.row();
        row.cell(version.name());
        
        let (channel_id, channel_fmt, is_latest) = match version.channel() {
            VersionChannel::Release => ("release", "Release", manifest.latest_release_name() == version.name()),
            VersionChannel::Snapshot => ("snapshot", "Snapshot", manifest.latest_snapshot_name() == version.name()),
            VersionChannel::Beta => ("beta", "Beta", false),
            VersionChannel::Alpha => ("alpha", "Alpha", false),
        };
        
        if is_latest {
            row.cell(format_args!("{channel_id}*")).format(format_args!("{channel_fmt}*"));
        } else {
            row.cell(format_args!("{channel_id}")).format(format_args!("{channel_fmt}"));
        }

        // Raw output is RFC3339 of FixedOffset time, format is local time.
        let mut cell = row.cell(&version.release_time().to_rfc3339());
        let local_release_date = version.release_time().with_timezone(&Local);
        let local_release_data_fmt: _ = version.release_time().format(DATE_FORMAT);
        let delta = today.signed_duration_since(&local_release_date);

        if is_latest || version.channel() == VersionChannel::Release || delta <= TimeDelta::weeks(4) {
            cell.format(format_args!("{} ({})", local_release_data_fmt, TimeDeltaFmt(delta)));
        } else {
            cell.format(format_args!("{}", local_release_data_fmt));
        }

    }

    ExitCode::SUCCESS

}

fn search_local(cli: &mut Cli, args: &SearchArgs) -> ExitCode {

    let versions_dir = cli.main_dir.join("versions");

    let reader = match fs::read_dir(&versions_dir) {
        Ok(reader) => reader,
        Err(e) => {
            
            cli.out.log("error_search_local")
                .arg(versions_dir.display())
                .error("Failed to read local versions directory:")
                .additional(versions_dir.display());

            log_any_error(cli, &e, false, true);
            return ExitCode::FAILURE;

        }
    };
    
    // Construct the table.
    let mut table = cli.out.table(2);

    {
        let mut row = table.row();
        row.cell("name").format("Name");
        row.cell("last_modified_date").format("Last modified date");
    }
    
    table.sep();

    for entry in reader.take(args.limit) {
        
        let Ok(entry) = entry else { continue };
        let Ok(entry_type) = entry.file_type() else { continue };
        if !entry_type.is_dir() { continue };

        let mut version_dir = entry.path();
        let Some(version_id) = version_dir.file_name().unwrap().to_str() else { continue };
        let version_id = version_id.to_string();

        version_dir.push(&version_id);
        version_dir.as_mut_os_string().push(".json");

        let Ok(version_metadata) = version_dir.metadata() else { continue };
        let Ok(version_last_modified) = version_metadata.modified() else { continue };
        let version_last_modified = DateTime::<Local>::from(version_last_modified);

        if !args.match_filter(&version_id) {
            continue;
        }
        
        // We use the local timezone for both raw and format cells.
        let mut row = table.row();
        row.cell(&version_id);
        row.cell(&version_last_modified.to_rfc3339())
            .format(version_last_modified.format(DATE_FORMAT));

    }

    ExitCode::SUCCESS

}

fn search_fabric(cli: &mut Cli, args: &SearchArgs, loader: fabric::Loader, game: bool) -> ExitCode {

    use fabric::Api;

    let api = Api::new(loader);

    if game {

        let versions = match api.request_game_versions() {
            Ok(v) => v,
            Err(e) => {
                log_fabric_error(cli, &e, loader);
                return ExitCode::FAILURE;
            }
        };

        let mut table = cli.out.table(2);
    
        {
            let mut row = table.row();
            row.cell("game_version").format("Game version");
            row.cell("channel").format("Channel");
        }
        
        table.sep();

        for version in versions.iter().take(args.limit) {
            
            if !args.match_filter(version.name()) {
                continue;
            }

            if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) {
                continue;
            }

            let mut row = table.row();
            row.cell(version.name());
            row.cell(if version.is_stable() { "stable" } else { "unstable" })
                .format(if version.is_stable() { "Stable" } else { "Unstable" });

        }

    } else {

        let versions = match api.request_loader_versions(None) {
            Ok(v) => v,
            Err(e) => {
                log_fabric_error(cli, &e, loader);
                return ExitCode::FAILURE;
            }
        };

        let mut table = cli.out.table(2);
    
        {
            let mut row = table.row();
            row.cell("loader_version").format("Loader version");
            row.cell("channel").format("Channel");
        }
        
        table.sep();

        for version in versions.iter().take(args.limit) {
            
            if !args.match_filter(version.name()) {
                continue;
            }
            
            if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) {
                continue;
            }

            let mut row = table.row();
            row.cell(version.name());
            row.cell(if version.is_stable() { "stable" } else { "unstable" })
                .format(if version.is_stable() { "Stable" } else { "Unstable" });

        }

    }

    ExitCode::SUCCESS

}

fn search_forge(cli: &mut Cli, args: &SearchArgs, loader: forge::Loader) -> ExitCode {

    use forge::Repo;

    // Start by requesting the repository!
    let repo = match Repo::request(loader) {
        Ok(repo) => repo,
        Err(e) => {
            log_forge_error(cli, &e, loader);
            return ExitCode::FAILURE;
        }
    };

    // Now we construct the table...
    let mut table = cli.out.table(3);

    {
        let mut row = table.row();
        row.cell("version").format("Version");
        row.cell("game_version").format("Game version");
        row.cell("channel").format("Channel");
    }
    
    table.sep();

    // Forge .xml repositories are not sorted at all and therefore we have to sort them
    // here for the sorting to make sense to the user.
    let mut versions = Vec::new();
    for version in repo.iter().take(args.limit) {
        
        if !args.match_filter(version.name()) {
            continue;
        }

        if !args.match_game_version(version.game_version()) {
            continue;
        }

        if !args.match_channel(SearchChannel::new_stable_or_unstable(version.is_stable())) {
            continue;
        }

        versions.push(version);
        
    }

    versions.sort_by_cached_key(|version| {

        // We take the maximum numbers count used for some forge versions.
        let version = version.name();

        // Special case for neoforge...
        if loader == forge::Loader::NeoForge && version == "47.1.82" {
            [1, 20, 1, 47, 1, 82, 0]
        } else {
            // We do a naive sorting where we just split each parsable number 
            let mut numbers = [0u16; 7];
            for (index, part) in version
                .split(|c: char| !c.is_numeric())
                .filter(|s| !s.is_empty())
                .take(numbers.len())
                .enumerate() {
                numbers[index] = part.parse().unwrap_or(0);
            }
            numbers
        }.map(|n| u16::MAX - n)  // Descending order!

    });

    for version in versions {

        let mut row = table.row();
        row.cell(version.name());
        row.cell(version.game_version());
        row.cell(if version.is_stable() { "stable" } else { "unstable" })
            .format(if version.is_stable() { "Stable" } else { "Unstable" });

    }

    ExitCode::SUCCESS

}