upstream-rs 1.4.2

Fetch package updates directly from the source.
use anyhow::Result;
use console::style;
use indicatif::HumanBytes;

use crate::{
    models::{
        common::enums::{Channel, Filetype, Provider},
        upstream::Package,
    },
    providers::provider_manager::{AssetCandidate, ProviderManager},
    services::storage::config_storage::ConfigStorage,
    utils::static_paths::UpstreamPaths,
};

pub async fn run(
    repo_slug: String,
    provider: Option<Provider>,
    base_url: Option<String>,
    channel: Channel,
    limit: u32,
    verbose: bool,
) -> Result<()> {
    let effective_provider = provider.unwrap_or_else(|| infer_provider(&repo_slug));
    let paths = UpstreamPaths::new();
    let config = ConfigStorage::new(&paths.config.config_file)?;

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

    let provider_manager =
        ProviderManager::new(github_token, gitlab_token, gitea_token, base_url.as_deref())?;

    println!(
        "{}",
        style(format!(
            "Probing '{}' via {} ...",
            repo_slug, effective_provider
        ))
        .cyan()
    );

    let mut releases = provider_manager
        .get_releases(&repo_slug, &effective_provider, Some(limit), Some(limit))
        .await?;

    releases = filter_by_channel(releases, &channel);
    releases.sort_by(|a, b| b.version.cmp(&a.version));

    if releases.is_empty() {
        println!("No releases found for channel '{}'.", channel);
        return Ok(());
    }

    let probe_package = Package::with_defaults(
        String::new(),
        repo_slug.clone(),
        Filetype::Auto,
        None,
        None,
        channel.clone(),
        effective_provider.clone(),
        base_url.clone(),
    );

    let rows = build_probe_rows(&releases, &provider_manager, &probe_package);
    let widths = ProbeColumnWidths::from_rows(&rows);

    let header = format!(
        "{:<id$} {:<state$} {:<tag$} {:<ver$} {:<pubd$} {:<assets$} {}",
        "ID",
        "State",
        "Tag",
        "Version",
        "Published",
        "Assets",
        "Top Candidate",
        id = widths.id,
        state = widths.state,
        tag = widths.tag,
        ver = widths.version,
        pubd = widths.published,
        assets = widths.assets
    );
    println!("{}", style(header).bold());
    println!("{}", "-".repeat(widths.table_width()));

    for row in &rows {
        println!(
            "{:<id$} {} {:<tag$} {:<ver$} {:<pubd$} {:<assets$} {}",
            row.row_id,
            format_state_cell(&row.state, widths.state),
            truncate(&row.tag, widths.tag),
            truncate(&row.version, widths.version),
            row.published,
            row.assets_count,
            truncate(&row.top_candidate, widths.top_candidate),
            id = widths.id,
            tag = widths.tag,
            ver = widths.version,
            pubd = widths.published,
            assets = widths.assets
        );

        if verbose {
            render_candidates(row);
        }
    }

    Ok(())
}

fn render_candidates(row: &ProbeRow) {
    let Some(candidates) = row.candidates.as_ref() else {
        println!(
            "     candidates: failed ({})",
            truncate(row.candidate_error.as_deref().unwrap_or("unknown"), 48)
        );
        return;
    };

    if candidates.is_empty() {
        println!("     candidates: none");
        return;
    }

    println!("     candidates:");
    for (rank, candidate) in candidates.iter().take(6).enumerate() {
        let asset = &candidate.asset;
        println!(
            "       #{} {:<44} {:>11} {:<10} score={}",
            rank + 1,
            truncate(&asset.name, 46),
            HumanBytes(asset.size),
            format!("{:?}", asset.filetype),
            candidate.score
        );
    }
    if candidates.len() > 6 {
        println!("       ... and {} more", candidates.len() - 6);
    }
}

fn build_probe_rows(
    releases: &[crate::models::provider::Release],
    provider_manager: &ProviderManager,
    probe_package: &Package,
) -> Vec<ProbeRow> {
    releases
        .iter()
        .enumerate()
        .map(|(idx, release)| {
            let candidates_result = provider_manager.get_candidate_assets(release, probe_package);

            let (top_candidate, candidates, candidate_error) = match candidates_result {
                Ok(list) => {
                    let top = list
                        .first()
                        .map(|c| format!("{} ({})", c.asset.name, c.score))
                        .unwrap_or_else(|| "-".to_string());
                    (top, Some(list), None)
                }
                Err(err) => ("n/a".to_string(), None, Some(err.to_string())),
            };

            ProbeRow {
                row_id: format!("R{:02}", idx + 1),
                state: release_state(release.is_draft, release.is_prerelease),
                tag: release.tag.clone(),
                version: release.version.to_string(),
                published: release.published_at.format("%Y-%m-%d %H:%M").to_string(),
                assets_count: release.assets.len(),
                top_candidate,
                candidates,
                candidate_error,
            }
        })
        .collect()
}

fn release_state(is_draft: bool, is_prerelease: bool) -> ReleaseState {
    match (is_draft, is_prerelease) {
        (false, false) => ReleaseState::Release,
        (false, true) => ReleaseState::Preview,
        (true, false) => ReleaseState::Draft,
        (true, true) => ReleaseState::DraftPre,
    }
}

fn format_state_cell(state: &ReleaseState, width: usize) -> String {
    let padded = format!("{:<width$}", state.label(), width = width);
    match state {
        ReleaseState::Release => style(padded).green().to_string(),
        ReleaseState::Preview => style(padded).yellow().to_string(),
        ReleaseState::Draft => style(padded).blue().to_string(),
        ReleaseState::DraftPre => style(padded).magenta().to_string(),
    }
}

fn infer_provider(repo_or_url: &str) -> Provider {
    let value = repo_or_url.trim().to_lowercase();
    if value.starts_with("http://") || value.starts_with("https://") {
        Provider::WebScraper
    } else {
        Provider::Github
    }
}

fn filter_by_channel(
    mut releases: Vec<crate::models::provider::Release>,
    channel: &Channel,
) -> Vec<crate::models::provider::Release> {
    match channel {
        Channel::Stable => {
            releases.retain(|r| !r.is_prerelease && !ProviderManager::is_nightly_release(&r.tag))
        }
        Channel::Preview => releases.retain(ProviderManager::is_preview_release),
        Channel::Nightly => releases.retain(|r| ProviderManager::is_nightly_release(&r.tag)),
    }
    releases
}

fn truncate(value: &str, max: usize) -> String {
    let char_count = value.chars().count();
    if char_count <= max {
        return value.to_string();
    }

    let mut out = String::new();
    for ch in value.chars().take(max.saturating_sub(3)) {
        out.push(ch);
    }
    out.push_str("...");
    out
}

#[derive(Debug, Clone)]
struct ProbeRow {
    row_id: String,
    state: ReleaseState,
    tag: String,
    version: String,
    published: String,
    assets_count: usize,
    top_candidate: String,
    candidates: Option<Vec<AssetCandidate>>,
    candidate_error: Option<String>,
}

#[derive(Debug, Clone)]
enum ReleaseState {
    Release,
    Preview,
    Draft,
    DraftPre,
}

impl ReleaseState {
    fn label(&self) -> &'static str {
        match self {
            ReleaseState::Release => "release",
            ReleaseState::Preview => "preview",
            ReleaseState::Draft => "draft",
            ReleaseState::DraftPre => "draft+pre",
        }
    }
}

struct ProbeColumnWidths {
    id: usize,
    state: usize,
    tag: usize,
    version: usize,
    published: usize,
    assets: usize,
    top_candidate: usize,
}

impl ProbeColumnWidths {
    fn from_rows(rows: &[ProbeRow]) -> Self {
        let id = rows
            .iter()
            .map(|r| r.row_id.chars().count())
            .max()
            .unwrap_or(2)
            .max("ID".len());
        let state = rows
            .iter()
            .map(|r| r.state.label().chars().count())
            .max()
            .unwrap_or(5)
            .max("State".len());
        let tag = rows
            .iter()
            .map(|r| r.tag.chars().count())
            .max()
            .unwrap_or(3)
            .max("Tag".len())
            .min(42);
        let version = rows
            .iter()
            .map(|r| r.version.chars().count())
            .max()
            .unwrap_or(7)
            .max("Version".len())
            .min(22);
        let published = rows
            .iter()
            .map(|r| r.published.chars().count())
            .max()
            .unwrap_or(9)
            .max("Published".len());
        let assets = rows
            .iter()
            .map(|r| r.assets_count.to_string().chars().count())
            .max()
            .unwrap_or(1)
            .max("Assets".len());
        let top_candidate = rows
            .iter()
            .map(|r| r.top_candidate.chars().count())
            .max()
            .unwrap_or(13)
            .max("Top Candidate".len())
            .min(44);

        Self {
            id,
            state,
            tag,
            version,
            published,
            assets,
            top_candidate,
        }
    }

    fn table_width(&self) -> usize {
        self.id
            + self.state
            + self.tag
            + self.version
            + self.published
            + self.assets
            + self.top_candidate
            + 6 // spaces between 7 columns
    }
}