use anyhow::Result;
use console::style;
use indicatif::HumanBytes;
use serde::Serialize;
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,
json: 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_with_download_config(
github_token,
gitlab_token,
gitea_token,
app_config.download,
)?;
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() {
if json {
let result = json_probe_result(
&repo_slug,
&effective_repo_slug,
&effective_provider,
effective_base_url.as_deref(),
&channel,
probe_notes,
&[],
verbose,
);
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
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);
if json {
let result = json_probe_result(
&repo_slug,
&effective_repo_slug,
&effective_provider,
effective_base_url.as_deref(),
&channel,
probe_notes,
&rows,
verbose,
);
println!("{}", serde_json::to_string_pretty(&result)?);
return Ok(());
}
pager::page_text(
Some("Probe"),
&format_probe_results(&probe_notes, &rows, verbose),
)?;
Ok(())
}
#[derive(Serialize)]
struct JsonProbeResult {
source: JsonProbeSource,
channel: String,
notes: Vec<String>,
releases: Vec<JsonProbeRelease>,
}
#[derive(Serialize)]
struct JsonProbeSource {
input: String,
repo_slug: String,
provider: String,
base_url: Option<String>,
}
#[derive(Serialize)]
struct JsonProbeRelease {
id: String,
state: &'static str,
tag: String,
version: String,
published: String,
assets_count: usize,
top_candidate: String,
candidates: Option<Vec<JsonAssetCandidate>>,
candidate_error: Option<String>,
}
#[derive(Serialize)]
struct JsonAssetCandidate {
rank: usize,
score: i32,
id: u64,
name: String,
download_url: String,
size: u64,
created_at: String,
filetype: String,
target_os: Option<String>,
target_arch: Option<String>,
}
#[allow(clippy::too_many_arguments)]
fn json_probe_result(
input: &str,
repo_slug: &str,
provider: &Provider,
base_url: Option<&str>,
channel: &Channel,
notes: Vec<String>,
rows: &[ProbeRow],
include_candidates: bool,
) -> JsonProbeResult {
JsonProbeResult {
source: JsonProbeSource {
input: input.to_string(),
repo_slug: repo_slug.to_string(),
provider: provider.to_string(),
base_url: base_url.map(str::to_string),
},
channel: channel.to_string(),
notes,
releases: rows
.iter()
.map(|row| JsonProbeRelease {
id: row.row_id.clone(),
state: row.state.label(),
tag: row.tag.clone(),
version: row.version.clone(),
published: row.published.clone(),
assets_count: row.assets_count,
top_candidate: row.top_candidate.clone(),
candidates: include_candidates.then(|| json_asset_candidates(row)),
candidate_error: row.candidate_error.clone(),
})
.collect(),
}
}
fn json_asset_candidates(row: &ProbeRow) -> Vec<JsonAssetCandidate> {
row.candidates
.as_deref()
.unwrap_or_default()
.iter()
.enumerate()
.map(|(idx, candidate)| {
let asset = &candidate.asset;
JsonAssetCandidate {
rank: idx + 1,
score: candidate.score,
id: asset.id,
name: asset.name.clone(),
download_url: asset.download_url.clone(),
size: asset.size,
created_at: asset.created_at.to_rfc3339(),
filetype: asset.filetype.to_string(),
target_os: asset.target_os.as_ref().map(|value| format!("{value:?}")),
target_arch: asset.target_arch.as_ref().map(|value| format!("{value:?}")),
}
})
.collect()
}
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 }
}
#[cfg(test)]
mod tests {
use super::{JsonProbeResult, ProbeRow, ReleaseState, json_probe_result};
use crate::{
models::{
common::enums::{Channel, Filetype, Provider},
provider::Asset,
},
providers::asset_selector::AssetCandidate,
};
use chrono::TimeZone;
#[test]
fn json_probe_result_includes_source_releases_and_candidates() {
let created_at = chrono::Utc.with_ymd_and_hms(2026, 6, 12, 1, 2, 3).unwrap();
let row = ProbeRow {
row_id: "R01".to_string(),
state: ReleaseState::Release,
tag: "v1.2.3".to_string(),
version: "1.2.3".to_string(),
published: "2026-06-12 01:02".to_string(),
assets_count: 1,
top_candidate: "tool.tar.gz (42)".to_string(),
candidates: Some(vec![AssetCandidate {
asset: Asset {
download_url: "https://example.invalid/tool.tar.gz".to_string(),
id: 7,
name: "tool.tar.gz".to_string(),
size: 1234,
created_at,
filetype: Filetype::Archive,
target_os: None,
target_arch: None,
},
score: 42,
}]),
candidate_error: None,
};
let result: JsonProbeResult = json_probe_result(
"owner/tool",
"owner/tool",
&Provider::Github,
None,
&Channel::Stable,
vec!["Probing 'owner/tool' via github".to_string()],
&[row],
true,
);
let json = serde_json::to_value(result).expect("serialize probe result");
assert_eq!(json["source"]["provider"], "github");
assert_eq!(json["channel"], "Stable");
assert_eq!(json["releases"][0]["state"], "release");
assert_eq!(json["releases"][0]["candidates"][0]["rank"], 1);
assert_eq!(
json["releases"][0]["candidates"][0]["filetype"],
"Compressed archive"
);
}
}