use anyhow::Result;
use console::style;
use indicatif::HumanBytes;
use std::fmt::Write as _;
use crate::{
models::{
common::enums::{Channel, Filetype, Provider},
upstream::Package,
},
output::pager,
providers::discovery::DiscoveryRequest,
providers::{asset_selector::AssetCandidate, provider_manager::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 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 mut probe_notes = Vec::new();
let (effective_repo_slug, effective_provider, effective_base_url, mut releases) =
if let Some(provider) = provider {
probe_notes.push(format!("Probing '{}' via {}", repo_slug, provider));
let releases = provider_manager
.get_releases(
&repo_slug,
&provider,
Some(limit),
Some(limit),
base_url.as_deref(),
)
.await?;
(repo_slug.clone(), provider, base_url.clone(), releases)
} else {
let discovery = provider_manager
.discover_source(DiscoveryRequest {
source: repo_slug.clone(),
channel: channel.clone(),
package_name: String::new(),
filetype: Filetype::Auto,
match_pattern: None,
exclude_pattern: None,
base_url_override: base_url.clone(),
limit,
})
.await?;
probe_notes.push(format!(
"Probing '{}' as '{}' via {}",
repo_slug, discovery.source.repo_slug, discovery.source.provider
));
(
discovery.source.repo_slug,
discovery.source.provider,
discovery.source.base_url,
discovery.releases,
)
};
releases = filter_by_channel(releases, &channel);
releases.sort_by(|a, b| b.version.cmp(&a.version));
if releases.is_empty() {
println!(
"{}",
crate::output::warning(format!("No releases found for channel '{}'.", channel))
);
return Ok(());
}
let probe_package = Package::with_defaults(
String::new(),
effective_repo_slug.clone(),
Filetype::Auto,
None,
None,
channel.clone(),
effective_provider.clone(),
effective_base_url.clone(),
);
let rows = build_probe_rows(&releases, &provider_manager, &probe_package);
pager::page_text(
Some("Probe"),
&format_probe_results(&probe_notes, &rows, verbose),
)?;
Ok(())
}
fn write_candidates(out: &mut String, row: &ProbeRow) {
let Some(candidates) = row.candidates.as_ref() else {
writeln!(
out,
" candidates: failed ({})",
truncate(row.candidate_error.as_deref().unwrap_or("unknown"), 48)
)
.expect("write candidate error");
return;
};
if candidates.is_empty() {
writeln!(out, " candidates: none").expect("write empty candidates");
return;
}
writeln!(out, " candidates:").expect("write candidates label");
for (rank, candidate) in candidates.iter().take(6).enumerate() {
let asset = &candidate.asset;
writeln!(
out,
" #{} {:<44} {:>11} {:<10} score={}",
rank + 1,
truncate(&asset.name, 46),
HumanBytes(asset.size),
format!("{:?}", asset.filetype),
candidate.score
)
.expect("write candidate row");
}
if candidates.len() > 6 {
writeln!(out, " ... and {} more", candidates.len() - 6)
.expect("write candidate overflow");
}
}
fn format_probe_results(notes: &[String], rows: &[ProbeRow], verbose: bool) -> String {
let widths = ProbeColumnWidths::from_rows(rows);
let mut out = String::new();
for note in notes {
writeln!(out, " {note}").expect("write probe note");
}
if !notes.is_empty() {
writeln!(out).expect("write probe note spacer");
}
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
);
writeln!(out, "{}", style(header).bold()).expect("write probe header");
writeln!(out, "{}", "-".repeat(widths.table_width())).expect("write probe divider");
for row in rows {
writeln!(
out,
"{:<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
)
.expect("write probe row");
if verbose {
write_candidates(&mut out, row);
}
}
out
}
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 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 }
}