Skip to main content

upstream_rs/application/operations/
probe_operation.rs

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}