knott 0.1.7

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
use crate::aur::{AurClient, AurPackage};
use crate::cli::{Options, SortBy};
use crate::localdb::LocalDb;
use crate::menu::parse_selection;
use crate::output;
use crate::pacman::{RepoPackage, Toolchain};

use anyhow::Result;
use std::cmp::Ordering;
use std::io;

#[derive(Debug, Clone)]
pub struct SearchHit {
    pub source: String,
    pub name: String,
    pub version: String,
    pub desc: Option<String>,
    pub votes: i64,
    pub popularity: f64,
    pub last_modified: u64,
    pub installed: Option<String>,
}

pub async fn print_search(
    options: &Options,
    tools: &Toolchain,
    aur: &AurClient,
    terms: &[String],
) -> Result<i32> {
    let hits = search(options, tools, aur, terms).await?;
    print_hits(options, &hits, true);
    Ok(hits.is_empty() as i32)
}

pub async fn interactive_select(
    options: &Options,
    tools: &Toolchain,
    aur: &AurClient,
    terms: &[String],
) -> Result<Vec<String>> {
    let hits = search(options, tools, aur, terms).await?;
    if hits.is_empty() {
        return Ok(Vec::new());
    }

    print_hits(options, &hits, false);

    if options.no_confirm {
        return Ok(vec![hits[0].name.clone()]);
    }

    output::prompt("Packages to install (eg: 1 2, 1-3, ^4, name): ")?;

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let names = hits.iter().map(|hit| hit.name.clone()).collect::<Vec<_>>();

    Ok(parse_selection(&input, hits.len(), &names)
        .into_iter()
        .map(|index| hits[index].name.clone())
        .collect())
}

async fn search(
    options: &Options,
    tools: &Toolchain,
    aur: &AurClient,
    terms: &[String],
) -> Result<Vec<SearchHit>> {
    let repo_future = async {
        if options.mode.repo() {
            tools.search_repo(terms).await
        } else {
            Ok(Vec::new())
        }
    };

    let aur_future = async {
        if options.mode.aur() {
            aur.search_narrowed(terms, options.search_by).await
        } else {
            Ok(Vec::new())
        }
    };

    let (repo, aur_pkgs) = tokio::try_join!(repo_future, aur_future)?;
    let mut hits = Vec::with_capacity(repo.len() + aur_pkgs.len());
    hits.extend(repo.into_iter().map(SearchHit::from_repo));
    if !aur_pkgs.is_empty() {
        let local = LocalDb::load_default().unwrap_or_else(|_| LocalDb::empty());
        hits.extend(
            aur_pkgs
                .into_iter()
                .map(|pkg| SearchHit::from_aur(pkg, &local)),
        );
    }

    sort_hits(&mut hits, terms, options.sort_by);

    if let Some(limit) = options.limit {
        hits.truncate(limit);
    }

    Ok(hits)
}

impl SearchHit {
    fn from_repo(pkg: RepoPackage) -> Self {
        Self {
            source: pkg.repo,
            name: pkg.name,
            version: pkg.version,
            desc: pkg.desc,
            votes: -1,
            popularity: -1.0,
            last_modified: 0,
            installed: pkg.installed,
        }
    }

    fn from_aur(pkg: AurPackage, local: &LocalDb) -> Self {
        let installed = local.get(&pkg.name).map(|local| {
            if local.version == pkg.version {
                "installed".to_string()
            } else {
                format!("installed: {}", local.version)
            }
        });

        Self {
            source: "aur".to_string(),
            name: pkg.name,
            version: pkg.version,
            desc: pkg.description,
            votes: pkg.num_votes,
            popularity: pkg.popularity,
            last_modified: pkg.last_modified,
            installed,
        }
    }
}

fn sort_hits(hits: &mut [SearchHit], terms: &[String], sort_by: SortBy) {
    let terms = terms
        .iter()
        .map(|term| term.to_lowercase())
        .collect::<Vec<_>>();

    hits.sort_by(|left, right| match sort_by {
        SortBy::Relevance => compare_relevance(left, right, &terms),
        SortBy::Votes => right
            .votes
            .cmp(&left.votes)
            .then_with(|| left.name.cmp(&right.name)),
        SortBy::Popularity => right
            .popularity
            .partial_cmp(&left.popularity)
            .unwrap_or(Ordering::Equal)
            .then_with(|| left.name.cmp(&right.name)),
        SortBy::Name => left.name.cmp(&right.name),
        SortBy::Modified => right
            .last_modified
            .cmp(&left.last_modified)
            .then_with(|| left.name.cmp(&right.name)),
    });
}

fn compare_relevance(left: &SearchHit, right: &SearchHit, terms: &[String]) -> Ordering {
    relevance(left, terms)
        .cmp(&relevance(right, terms))
        .then_with(|| source_rank(&left.source).cmp(&source_rank(&right.source)))
        .then_with(|| {
            right
                .popularity
                .partial_cmp(&left.popularity)
                .unwrap_or(Ordering::Equal)
        })
        .then_with(|| left.name.cmp(&right.name))
}

fn relevance(hit: &SearchHit, terms: &[String]) -> u8 {
    if terms.is_empty() {
        return 3;
    }

    let name = hit.name.to_lowercase();
    if terms.contains(&name) {
        0
    } else if terms.iter().all(|term| name.starts_with(term)) {
        1
    } else if terms.iter().all(|term| name.contains(term)) {
        2
    } else {
        let Some(desc) = hit.desc.as_deref() else {
            return 5;
        };
        let desc = desc.to_lowercase();
        if terms.iter().all(|term| desc.contains(term)) {
            4
        } else {
            5
        }
    }
}

fn source_rank(source: &str) -> u8 {
    if source == "aur" {
        1
    } else {
        0
    }
}

fn print_hits(options: &Options, hits: &[SearchHit], omit_numbers: bool) {
    for (index, hit) in hits.iter().enumerate() {
        if options.quiet {
            output::inline_line(&hit.name);
            continue;
        }

        let mut line = String::new();
        if !omit_numbers {
            line.push_str(&format!("{:>3} ", index + 1));
        }

        line.push_str(&format!(
            "{}/{} {}",
            output::inline(&hit.source),
            output::inline(&hit.name),
            output::inline(&hit.version)
        ));

        if hit.votes >= 0 {
            line.push_str(&format!(" (+{} ~{:.2})", hit.votes, hit.popularity));
        }

        if let Some(installed) = &hit.installed {
            line.push_str(&format!(" [{}]", output::inline(installed)));
        }

        output::line(line);

        if let Some(desc) = &hit.desc {
            output::line(format!("    {}", output::inline(desc)));
        }
    }
}