upstream-rs 1.19.0

Fetch package updates directly from the source.
Documentation
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::Serialize;

use crate::{
    models::{common::enums::Provider, provider::RepositorySearchResult},
    output,
    output::pager,
    providers::provider_manager::ProviderManager,
    services::storage::config_storage::ConfigStorage,
    utils::static_paths::UpstreamPaths,
};
use std::fmt::Write as _;

pub async fn run(
    query_words: Vec<String>,
    provider: Option<Provider>,
    base_url: Option<String>,
    limit: u32,
    json: bool,
) -> Result<()> {
    let query = query_words.join(" ").trim().to_string();
    if query.is_empty() {
        if json {
            let result = json_search_result(
                &query,
                &provider.unwrap_or(Provider::Github),
                None,
                limit,
                &[],
            );
            println!("{}", serde_json::to_string_pretty(&result)?);
            return Ok(());
        }
        println!("{}", output::warning("Search query cannot be empty."));
        return Ok(());
    }

    let paths = UpstreamPaths::new()?;
    let config = ConfigStorage::new(&paths.config.config_file)?;
    let app_config = config.get_config();

    let github_token = app_config.github.api_token.as_deref();
    let gitlab_token = app_config.gitlab.api_token.as_deref();
    let gitea_token = app_config.gitea.api_token.as_deref();

    let provider_manager = ProviderManager::new(github_token, gitlab_token, gitea_token)?;
    let effective_provider = provider.unwrap_or(Provider::Github);

    let results = provider_manager
        .search_repositories(
            &query,
            &effective_provider,
            Some(limit.max(1)),
            base_url.as_deref(),
        )
        .await?;

    if results.is_empty() {
        if json {
            let result = json_search_result(
                &query,
                &effective_provider,
                base_url.as_deref(),
                limit,
                &results,
            );
            println!("{}", serde_json::to_string_pretty(&result)?);
            return Ok(());
        }
        println!("{}", output::warning("No repositories found."));
        return Ok(());
    }

    if json {
        let result = json_search_result(
            &query,
            &effective_provider,
            base_url.as_deref(),
            limit,
            &results,
        );
        println!("{}", serde_json::to_string_pretty(&result)?);
        return Ok(());
    }

    let title = format!("Search: '{}' via {}", query, effective_provider);
    pager::page_text(Some(&title), &format_results(&results))?;
    Ok(())
}

#[derive(Serialize)]
struct JsonSearchResult {
    query: String,
    provider: String,
    base_url: Option<String>,
    limit: u32,
    results: Vec<JsonRepositorySearchResult>,
}

#[derive(Serialize)]
struct JsonRepositorySearchResult {
    repo_slug: String,
    display_name: String,
    description: String,
    stars: u64,
    language: String,
    updated_at: String,
}

fn json_search_result(
    query: &str,
    provider: &Provider,
    base_url: Option<&str>,
    limit: u32,
    results: &[RepositorySearchResult],
) -> JsonSearchResult {
    JsonSearchResult {
        query: query.to_string(),
        provider: provider.to_string(),
        base_url: base_url.map(str::to_string),
        limit,
        results: results
            .iter()
            .map(|result| JsonRepositorySearchResult {
                repo_slug: result.repo_slug.clone(),
                display_name: result.display_name.clone(),
                description: result.description.clone(),
                stars: result.stars,
                language: result.language.clone(),
                updated_at: result.updated_at.to_rfc3339(),
            })
            .collect(),
    }
}

fn format_results(results: &[RepositorySearchResult]) -> String {
    let widths = SearchColumnWidths::from_rows(results);
    let mut out = String::new();

    writeln!(
        out,
        "{:<slug$} {:>stars$} {:<lang$} {:<updated$} Description",
        "Slug",
        "Stars",
        "Lang",
        "Updated",
        slug = widths.slug,
        stars = widths.stars,
        lang = widths.lang,
        updated = widths.updated,
    )
    .expect("write search header");
    writeln!(out, "{}", output::divider(widths.table_width())).expect("write search divider");

    for row in results {
        write_row(&mut out, row, &widths);
    }

    writeln!(out).expect("write search spacer");
    writeln!(out, "{} results - use --limit to see more", results.len())
        .expect("write search footer");
    out
}

fn write_row(out: &mut String, row: &RepositorySearchResult, widths: &SearchColumnWidths) {
    writeln!(
        out,
        "{:<slug$} {:>stars$} {:<lang$} {:<updated$} {}",
        truncate(&row.repo_slug, widths.slug),
        format_stars(row.stars),
        truncate(default_dash(&row.language), widths.lang),
        format_relative_updated(row.updated_at),
        truncate(default_dash(&row.description), widths.description),
        slug = widths.slug,
        stars = widths.stars,
        lang = widths.lang,
        updated = widths.updated,
    )
    .expect("write search row");
}

fn format_stars(stars: u64) -> String {
    if stars < 1_000 {
        return stars.to_string();
    }
    if stars < 1_000_000 {
        return format_with_suffix(stars, 1_000.0, "k");
    }
    format_with_suffix(stars, 1_000_000.0, "m")
}

fn format_with_suffix(value: u64, divisor: f64, suffix: &str) -> String {
    let scaled = value as f64 / divisor;
    if scaled >= 100.0 || (scaled.fract() == 0.0) {
        format!("{:.0}{suffix}", scaled)
    } else {
        format!("{:.1}{suffix}", scaled)
    }
}

fn format_relative_updated(updated_at: DateTime<Utc>) -> String {
    format_relative_updated_with_now(updated_at, Utc::now())
}

fn format_relative_updated_with_now(updated_at: DateTime<Utc>, now: DateTime<Utc>) -> String {
    let delta = now.signed_duration_since(updated_at);
    if delta.num_seconds() < 0 {
        return "today".to_string();
    }

    let days = delta.num_days();
    if days == 0 {
        return "today".to_string();
    }
    if days < 30 {
        return if days == 1 {
            "1 day ago".to_string()
        } else {
            format!("{days} days ago")
        };
    }

    let months = days / 30;
    if months < 12 {
        return if months == 1 {
            "1 month ago".to_string()
        } else {
            format!("{months} months ago")
        };
    }

    let years = months / 12;
    if years == 1 {
        "1 year ago".to_string()
    } else {
        format!("{years} years ago")
    }
}

fn default_dash(value: &str) -> &str {
    if value.trim().is_empty() { "-" } else { value }
}

fn truncate(value: &str, max: usize) -> String {
    output::truncate_end(value, max)
}

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

impl SearchColumnWidths {
    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| format_stars(r.stars).chars().count())
            .max()
            .unwrap_or(5)
            .max("Stars".len());

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

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

        let description = 72;

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

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

#[cfg(test)]
mod tests {
    use super::{format_relative_updated_with_now, format_stars, json_search_result, truncate};
    use crate::models::{common::enums::Provider, provider::RepositorySearchResult};
    use chrono::{Duration, TimeZone, Utc};

    #[test]
    fn format_stars_uses_compact_suffixes() {
        assert_eq!(format_stars(561), "561");
        assert_eq!(format_stars(1_000), "1k");
        assert_eq!(format_stars(9_645), "9.6k");
        assert_eq!(format_stars(63_520), "63.5k");
        assert_eq!(format_stars(1_250_000), "1.2m");
    }

    #[test]
    fn format_relative_updated_uses_readable_buckets() {
        let now = Utc.with_ymd_and_hms(2026, 5, 10, 0, 0, 0).unwrap();
        assert_eq!(format_relative_updated_with_now(now, now), "today");
        assert_eq!(
            format_relative_updated_with_now(now - Duration::days(2), now),
            "2 days ago"
        );
        assert_eq!(
            format_relative_updated_with_now(now - Duration::days(65), now),
            "2 months ago"
        );
        assert_eq!(
            format_relative_updated_with_now(now - Duration::days(800), now),
            "2 years ago"
        );
    }

    #[test]
    fn truncate_adds_ellipsis_at_fixed_width() {
        let value = "ripgrep recursively searches directories for regex patterns";
        let t1 = truncate(value, 32);
        let t2 = truncate(value, 32);
        assert_eq!(t1, t2);
        assert!(t1.ends_with("..."));
        assert_eq!(t1.chars().count(), 32);
    }

    #[test]
    fn json_search_result_preserves_repository_fields() {
        let updated_at = Utc.with_ymd_and_hms(2026, 6, 12, 1, 2, 3).unwrap();
        let result = json_search_result(
            "ripgrep",
            &Provider::Github,
            None,
            10,
            &[RepositorySearchResult {
                repo_slug: "BurntSushi/ripgrep".to_string(),
                display_name: "ripgrep".to_string(),
                description: "search tool".to_string(),
                stars: 51_000,
                language: "Rust".to_string(),
                updated_at,
            }],
        );
        let json = serde_json::to_value(result).expect("serialize search result");

        assert_eq!(json["query"], "ripgrep");
        assert_eq!(json["provider"], "github");
        assert_eq!(json["results"][0]["repo_slug"], "BurntSushi/ripgrep");
        assert_eq!(
            json["results"][0]["updated_at"],
            "2026-06-12T01:02:03+00:00"
        );
    }
}