upstream-rs 2.1.0

Fetch package updates directly from the source.
Documentation
use anyhow::Result;
use chrono::NaiveDate;
use std::fmt::Write as _;

use crate::{
    application::commands::{install, search},
    models::{
        common::enums::{Channel, Filetype, Provider, TrustMode},
        provider::{RepositorySearchFilters, RepositorySearchResult},
    },
    output,
};

#[allow(clippy::too_many_arguments)]
pub async fn run(
    query_words: Vec<String>,
    provider: Option<Provider>,
    base_url: Option<String>,
    limit: u32,
    language: Option<String>,
    topic: Option<String>,
    min_stars: Option<u64>,
    max_stars: Option<u64>,
    pushed_after: Option<NaiveDate>,
    include_forks: bool,
    include_archived: bool,
    name: Option<String>,
    kind: Filetype,
    channel: Channel,
    match_pattern: Option<String>,
    exclude_pattern: Option<String>,
    desktop: bool,
    trust_mode: TrustMode,
    dry_run: bool,
) -> Result<()> {
    let query = query_words.join(" ").trim().to_string();
    if query.is_empty() {
        println!("{}", output::warning("Search query cannot be empty."));
        return Ok(());
    }

    let search = search::search_repositories(
        query,
        provider,
        base_url,
        limit,
        RepositorySearchFilters::new(
            language,
            topic,
            min_stars,
            max_stars,
            pushed_after,
            include_forks,
            include_archived,
        ),
    )
    .await?;
    if search.results.is_empty() {
        println!("{}", output::warning("No repositories found."));
        return Ok(());
    }

    let choices = SearchChoiceTable::from_results(&search.results);
    let prompt = format!("Find: '{}' via {}", search.query, search.provider);

    let Some(selected) = output::select_from_table(prompt, &choices.headers, &choices.rows)? else {
        println!("{}", output::warning("Cancelled"));
        return Ok(());
    };

    let result = &search.results[selected];
    let inferred_name = default_package_name(result);
    let install_name = match name {
        Some(name) => name,
        None => output::prompt_text("Package name", Some(&inferred_name))?,
    };
    println!(
        "{}",
        output::title(format!("Selected {} as {}", result.repo_slug, install_name))
    );

    install::run(
        Some(install_name),
        result.repo_slug.clone(),
        kind,
        None,
        Some(search.provider),
        search.base_url,
        channel,
        match_pattern,
        exclude_pattern,
        desktop,
        trust_mode,
        dry_run,
    )
    .await
}

struct SearchChoiceTable {
    headers: Vec<String>,
    rows: Vec<String>,
}

impl SearchChoiceTable {
    fn from_results(results: &[RepositorySearchResult]) -> Self {
        let widths = SearchChoiceWidths::from_rows(results);
        let mut header = String::new();
        write!(
            header,
            "  {:<slug$} {:>stars$} {:<lang$} {:<updated$} Description",
            "Slug",
            "Stars",
            "Lang",
            "Updated",
            slug = widths.slug,
            stars = widths.stars,
            lang = widths.lang,
            updated = widths.updated,
        )
        .expect("write find header");
        let divider = format!("  {}", output::divider(widths.table_width()));

        let rows = results
            .iter()
            .map(|result| format_search_choice(result, &widths))
            .collect();

        Self {
            headers: vec![header, divider],
            rows,
        }
    }
}

fn format_search_choice(result: &RepositorySearchResult, widths: &SearchChoiceWidths) -> String {
    format!(
        "{:<slug$} {:>stars$} {:<lang$} {:<updated$} {}",
        search::truncate(&result.repo_slug, widths.slug),
        search::format_stars(result.stars),
        search::truncate(search::default_dash(&result.language), widths.lang),
        search::format_relative_updated(result.updated_at),
        search::truncate(
            search::default_dash(&result.description),
            widths.description
        ),
        slug = widths.slug,
        stars = widths.stars,
        lang = widths.lang,
        updated = widths.updated,
    )
}

struct SearchChoiceWidths {
    slug: usize,
    stars: usize,
    lang: usize,
    updated: usize,
    description: usize,
}

impl SearchChoiceWidths {
    fn from_rows(rows: &[RepositorySearchResult]) -> Self {
        let slug = rows
            .iter()
            .map(|r| r.repo_slug.chars().count())
            .max()
            .unwrap_or(4)
            .max("Slug".len())
            .min(36);

        let stars = rows
            .iter()
            .map(|r| search::format_stars(r.stars).chars().count())
            .max()
            .unwrap_or(5)
            .max("Stars".len());

        let lang = rows
            .iter()
            .map(|r| search::default_dash(&r.language).chars().count())
            .max()
            .unwrap_or(4)
            .max("Lang".len())
            .min(14);

        let updated = rows
            .iter()
            .map(|r| {
                search::format_relative_updated(r.updated_at)
                    .chars()
                    .count()
            })
            .max()
            .unwrap_or(10)
            .max("Updated".len())
            .max("2 years ago".len());

        Self {
            slug,
            stars,
            lang,
            updated,
            description: 72,
        }
    }

    fn table_width(&self) -> usize {
        self.slug + self.stars + self.lang + self.updated + self.description + 4
    }
}

fn default_package_name(result: &RepositorySearchResult) -> String {
    let candidate = result
        .repo_slug
        .rsplit('/')
        .next()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&result.display_name);

    sanitize_package_name(candidate)
}

fn sanitize_package_name(value: &str) -> String {
    let mut out = String::new();
    let mut previous_dash = false;

    for ch in value.trim().chars().flat_map(char::to_lowercase) {
        if ch.is_ascii_alphanumeric() {
            out.push(ch);
            previous_dash = false;
        } else if !previous_dash && !out.is_empty() {
            out.push('-');
            previous_dash = true;
        }
    }

    while out.ends_with('-') {
        out.pop();
    }

    if out.is_empty() {
        "package".to_string()
    } else {
        out
    }
}

#[cfg(test)]
mod tests {
    use super::{SearchChoiceTable, default_package_name, sanitize_package_name};
    use crate::models::provider::RepositorySearchResult;
    use chrono::{TimeZone, Utc};

    fn result(repo_slug: &str, display_name: &str) -> RepositorySearchResult {
        RepositorySearchResult {
            repo_slug: repo_slug.to_string(),
            display_name: display_name.to_string(),
            description: "fast search".to_string(),
            stars: 51_000,
            language: "Rust".to_string(),
            updated_at: Utc.with_ymd_and_hms(2026, 6, 13, 0, 0, 0).unwrap(),
        }
    }

    #[test]
    fn default_package_name_uses_repo_basename() {
        assert_eq!(
            default_package_name(&result("BurntSushi/ripgrep", "ripgrep")),
            "ripgrep"
        );
    }

    #[test]
    fn sanitize_package_name_keeps_alias_shell_friendly() {
        assert_eq!(sanitize_package_name("My Tool_2!"), "my-tool-2");
        assert_eq!(sanitize_package_name("..."), "package");
    }

    #[test]
    fn search_choice_table_contains_aligned_install_relevant_fields() {
        let table = SearchChoiceTable::from_results(&[result("BurntSushi/ripgrep", "ripgrep")]);
        let choice = &table.rows[0];

        assert!(table.headers[0].contains("Slug"));
        assert!(table.headers[0].contains("Stars"));
        assert!(table.headers[0].contains("Updated"));
        assert!(table.headers[1].trim().chars().all(|ch| ch == '-'));
        assert!(choice.contains("BurntSushi/ripgrep"));
        assert!(choice.contains("51k"));
        assert!(choice.contains("Rust"));
        assert!(choice.contains("fast search"));
    }
}