1use anyhow::{Result, anyhow};
2use std::collections::HashMap;
3
4use crate::{
5 models::{
6 common::enums::{Channel, Filetype, Provider},
7 provider::{Asset, Release},
8 upstream::Package,
9 },
10 providers::{
11 asset_selector::{AssetCandidate, AssetSelector},
12 discovery::infer_source,
13 provider_manager::ProviderManager,
14 },
15 services::packaging::disk_impact::{
16 DiskImpact, asset_size_estimate, install_impact_from_download,
17 },
18};
19
20pub struct ProbeRequest {
21 pub input: String,
22 pub provider: Option<Provider>,
23 pub base_url: Option<String>,
24 pub channel: Channel,
25 pub limit: Option<u32>,
26 pub tag: Option<String>,
27 pub include_incompatible: bool,
28}
29
30pub struct ProbeResult {
31 pub input: String,
32 pub repo_slug: String,
33 pub provider: Provider,
34 pub base_url: Option<String>,
35 pub channel: Channel,
36 pub notes: Vec<String>,
37 pub releases: Vec<Release>,
38 pub probe_package: Package,
39 pub rows: Vec<ProbeRow>,
40 pub choices: Vec<ProbeAssetChoice>,
41}
42
43pub struct ProbeInstallSelection {
44 pub package: Package,
45 pub release: Release,
46 pub asset: Asset,
47 pub disk_impact: DiskImpact,
48}
49
50pub struct ProbeOperation<'a> {
51 provider_manager: &'a ProviderManager,
52}
53
54impl<'a> ProbeOperation<'a> {
55 pub fn new(provider_manager: &'a ProviderManager) -> Self {
56 Self { provider_manager }
57 }
58
59 pub async fn probe(&self, request: ProbeRequest) -> Result<ProbeResult> {
60 let mut notes = Vec::new();
61 let (repo_slug, provider, base_url) = if let Some(provider) = request.provider.clone() {
62 notes.push(format!("Probing '{}' via {}", request.input, provider));
63 (request.input.clone(), provider, request.base_url.clone())
64 } else {
65 let mut discovery = infer_source(&request.input)?;
66 if let Some(base_url) = request.base_url.as_deref() {
67 discovery.base_url = Some(base_url.to_string());
68 }
69
70 notes.push(format!(
71 "Probing '{}' as '{}' via {}",
72 request.input, discovery.repo_slug, discovery.provider
73 ));
74
75 (discovery.repo_slug, discovery.provider, discovery.base_url)
76 };
77
78 let mut releases = self
79 .fetch_releases(&repo_slug, &provider, base_url.as_deref(), &request)
80 .await?;
81 releases.sort_by(|a, b| b.version.cmp(&a.version));
82
83 let probe_package = Package::with_defaults(
84 String::new(),
85 repo_slug.clone(),
86 Filetype::Auto,
87 None,
88 None,
89 request.channel.clone(),
90 provider.clone(),
91 base_url.clone(),
92 );
93 let rows = build_probe_rows(&releases, self.provider_manager, &probe_package);
94 let choices = build_probe_asset_choices(
95 &releases,
96 self.provider_manager,
97 &probe_package,
98 request.include_incompatible,
99 );
100
101 Ok(ProbeResult {
102 input: request.input,
103 repo_slug,
104 provider,
105 base_url,
106 channel: request.channel,
107 notes,
108 releases,
109 probe_package,
110 rows,
111 choices,
112 })
113 }
114
115 pub fn prepare_install_selection(
116 &self,
117 result: &ProbeResult,
118 selected_index: usize,
119 install_name: String,
120 ) -> Result<ProbeInstallSelection> {
121 let selected_choice = result
122 .choices
123 .get(selected_index)
124 .ok_or_else(|| anyhow!("Selected asset no longer exists"))?;
125 let selected_release = result
126 .releases
127 .get(selected_choice.release_index)
128 .cloned()
129 .ok_or_else(|| anyhow!("Selected release no longer exists"))?;
130 let selected_asset = selected_choice.asset.clone();
131 let generated = AssetSelector::new().generate_patterns_for_asset(
132 &selected_asset,
133 &selected_release.assets,
134 &install_name,
135 );
136
137 let package = Package::with_defaults(
138 install_name,
139 result.repo_slug.clone(),
140 selected_asset.filetype,
141 Some(generated.match_pattern.to_string()),
142 Some(generated.exclude_pattern.to_string()),
143 result.channel.clone(),
144 result.provider.clone(),
145 result.base_url.clone(),
146 );
147 let disk_impact = install_impact_from_download(asset_size_estimate(selected_asset.size));
148
149 Ok(ProbeInstallSelection {
150 package,
151 release: selected_release,
152 asset: selected_asset,
153 disk_impact,
154 })
155 }
156
157 async fn fetch_releases(
158 &self,
159 repo_slug: &str,
160 provider: &Provider,
161 base_url: Option<&str>,
162 request: &ProbeRequest,
163 ) -> Result<Vec<Release>> {
164 if let Some(tag) = request.tag.as_deref().map(str::trim) {
165 if tag.is_empty() {
166 return Err(anyhow!("Probe tag cannot be empty"));
167 }
168
169 let release = self
170 .provider_manager
171 .get_release_by_tag(repo_slug, tag, provider, base_url)
172 .await
173 .map_err(|err| anyhow!("Failed to fetch release tag '{}': {}", tag, err))?;
174 return Ok(vec![release]);
175 }
176
177 if let Some(limit) = request.limit {
178 let releases = self
179 .provider_manager
180 .get_releases(repo_slug, provider, Some(limit), Some(limit), base_url)
181 .await?;
182 return Ok(filter_by_channel(releases, &request.channel));
183 }
184
185 let release = self
186 .provider_manager
187 .get_latest_release(repo_slug, provider, &request.channel, base_url)
188 .await?;
189 Ok(vec![release])
190 }
191}
192
193pub fn build_probe_asset_choices(
194 releases: &[Release],
195 provider_manager: &ProviderManager,
196 probe_package: &Package,
197 include_incompatible: bool,
198) -> Vec<ProbeAssetChoice> {
199 let mut choices = Vec::new();
200
201 for (release_index, release) in releases.iter().enumerate() {
202 let candidates = provider_manager
203 .get_candidate_assets(release, probe_package)
204 .unwrap_or_default();
205
206 if include_incompatible {
207 let score_by_asset_id: HashMap<u64, i32> = candidates
208 .into_iter()
209 .map(|candidate| (candidate.asset.id, candidate.score))
210 .collect();
211
212 for asset in &release.assets {
213 choices.push(ProbeAssetChoice {
214 release_index,
215 release_tag: release.tag.clone(),
216 release_state: release_state(release.is_draft, release.is_prerelease),
217 asset: asset.clone(),
218 score: score_by_asset_id.get(&asset.id).copied(),
219 });
220 }
221 } else {
222 choices.extend(candidates.into_iter().map(|candidate| ProbeAssetChoice {
223 release_index,
224 release_tag: release.tag.clone(),
225 release_state: release_state(release.is_draft, release.is_prerelease),
226 asset: candidate.asset,
227 score: Some(candidate.score),
228 }));
229 }
230 }
231
232 choices
233}
234
235pub fn build_probe_rows(
236 releases: &[Release],
237 provider_manager: &ProviderManager,
238 probe_package: &Package,
239) -> Vec<ProbeRow> {
240 releases
241 .iter()
242 .enumerate()
243 .map(|(idx, release)| {
244 let candidates_result = provider_manager.get_candidate_assets(release, probe_package);
245
246 let (top_candidate, candidates, candidate_error) = match candidates_result {
247 Ok(list) => {
248 let top = list
249 .first()
250 .map(|c| format!("{} ({})", c.asset.name, c.score))
251 .unwrap_or_else(|| "-".to_string());
252 (top, Some(list), None)
253 }
254 Err(err) => ("n/a".to_string(), None, Some(err.to_string())),
255 };
256
257 ProbeRow {
258 row_id: format!("R{:02}", idx + 1),
259 state: release_state(release.is_draft, release.is_prerelease),
260 tag: release.tag.clone(),
261 version: release.version.to_string(),
262 published: release.published_at.format("%Y-%m-%d %H:%M").to_string(),
263 assets_count: release.assets.len(),
264 top_candidate,
265 candidates,
266 candidate_error,
267 }
268 })
269 .collect()
270}
271
272pub fn filter_by_channel(mut releases: Vec<Release>, channel: &Channel) -> Vec<Release> {
273 match channel {
274 Channel::Stable => {
275 releases.retain(|r| !r.is_prerelease && !ProviderManager::is_nightly_release(&r.tag))
276 }
277 Channel::Preview => releases.retain(ProviderManager::is_preview_release),
278 Channel::Nightly => releases.retain(|r| ProviderManager::is_nightly_release(&r.tag)),
279 }
280 releases
281}
282
283pub fn release_state(is_draft: bool, is_prerelease: bool) -> ReleaseState {
284 match (is_draft, is_prerelease) {
285 (false, false) => ReleaseState::Release,
286 (false, true) => ReleaseState::Preview,
287 (true, false) => ReleaseState::Draft,
288 (true, true) => ReleaseState::DraftPre,
289 }
290}
291
292#[derive(Debug, Clone)]
293pub struct ProbeAssetChoice {
294 pub release_index: usize,
295 pub release_tag: String,
296 pub release_state: ReleaseState,
297 pub asset: Asset,
298 pub score: Option<i32>,
299}
300
301#[derive(Debug, Clone)]
302pub struct ProbeRow {
303 pub row_id: String,
304 pub state: ReleaseState,
305 pub tag: String,
306 pub version: String,
307 pub published: String,
308 pub assets_count: usize,
309 pub top_candidate: String,
310 pub candidates: Option<Vec<AssetCandidate>>,
311 pub candidate_error: Option<String>,
312}
313
314#[derive(Debug, Clone)]
315pub enum ReleaseState {
316 Release,
317 Preview,
318 Draft,
319 DraftPre,
320}
321
322impl ReleaseState {
323 pub fn label(&self) -> &'static str {
324 match self {
325 ReleaseState::Release => "release",
326 ReleaseState::Preview => "preview",
327 ReleaseState::Draft => "draft",
328 ReleaseState::DraftPre => "draft+pre",
329 }
330 }
331}