Skip to main content

upstream_rs/application/operations/
build_operation.rs

1use anyhow::{Context, Result, anyhow, bail};
2
3use crate::application::operations::install_operation::InstallOperation;
4use crate::models::{
5    common::enums::{Channel, Filetype, Provider},
6    upstream::{InstallType, Package},
7};
8use crate::output;
9use crate::providers::discovery::{SourceKind, infer_source, normalize_source_for_provider};
10use crate::providers::provider_manager::ProviderManager;
11use crate::services::builder::scripts::BuildScriptAction;
12use crate::services::builder::{BuildProfile, BuildRequest, worker::BuildWorker};
13use crate::services::packaging::{
14    PackageTransactionContext,
15    disk_impact::{DiskImpact, asset_size_estimate, install_impact_from_download},
16};
17use crate::services::storage::package_storage::PackageStorage;
18use crate::services::trust::TrustedSignatureKeys;
19use crate::utils::static_paths::UpstreamPaths;
20
21pub struct BuildOperation<'a> {
22    provider_manager: &'a ProviderManager,
23    package_storage: &'a mut PackageStorage,
24    paths: &'a UpstreamPaths,
25    trusted_keys: TrustedSignatureKeys,
26}
27
28pub struct BuildCommandInput {
29    pub name: String,
30    pub repo_slug: String,
31    pub tag: Option<String>,
32    pub branch: Option<String>,
33    pub provider: Option<Provider>,
34    pub base_url: Option<String>,
35    pub channel: Channel,
36    pub desktop: bool,
37    pub build_profile: Option<BuildProfile>,
38    pub dry_run: bool,
39}
40
41impl<'a> BuildOperation<'a> {
42    pub fn new(
43        provider_manager: &'a ProviderManager,
44        package_storage: &'a mut PackageStorage,
45        paths: &'a UpstreamPaths,
46        trusted_keys: TrustedSignatureKeys,
47    ) -> Self {
48        Self {
49            provider_manager,
50            package_storage,
51            paths,
52            trusted_keys,
53        }
54    }
55
56    pub async fn build_and_install(&mut self, input: BuildCommandInput) -> Result<()> {
57        let (resolved_repo_slug, resolved_provider, resolved_base_url) = if let Some(selected) =
58            input.provider.as_ref()
59        {
60            let normalized_source = normalize_source_for_provider(
61                &input.repo_slug,
62                selected,
63                input.base_url.as_deref(),
64            );
65            let inferred_base_url = input.base_url.clone().or_else(|| {
66                infer_source(&input.repo_slug)
67                    .ok()
68                    .filter(|source| {
69                        source.provider == *selected && matches!(source.kind, SourceKind::ForgeUrl)
70                    })
71                    .and_then(|source| source.base_url)
72            });
73
74            (normalized_source, selected.clone(), inferred_base_url)
75        } else {
76            let mut discovered = infer_source(&input.repo_slug)?;
77            if let Some(base) = input.base_url.clone() {
78                discovered.base_url = Some(base);
79            }
80
81            match discovered.kind {
82                SourceKind::Repository | SourceKind::ForgeUrl => {}
83                SourceKind::DirectAsset | SourceKind::DownloadPage => {
84                    return Err(anyhow!(
85                        "Build requires a forge repository source (github/gitlab/gitea), got '{}'",
86                        input.repo_slug
87                    ));
88                }
89            }
90
91            (
92                discovered.repo_slug,
93                discovered.provider,
94                discovered.base_url,
95            )
96        };
97
98        if !matches!(
99            resolved_provider,
100            Provider::Github | Provider::Gitlab | Provider::Gitea
101        ) {
102            bail!("Build supports forge providers only (github/gitlab/gitea)");
103        }
104
105        if input.dry_run {
106            let disk_impact = self
107                .estimate_build_disk_impact(
108                    &input,
109                    &resolved_repo_slug,
110                    &resolved_provider,
111                    resolved_base_url.as_deref(),
112                )
113                .await;
114            if let Some(branch) = input.branch.as_deref() {
115                let commit = self
116                    .provider_manager
117                    .get_branch_head_sha(
118                        &resolved_repo_slug,
119                        &resolved_provider,
120                        branch,
121                        resolved_base_url.as_deref(),
122                    )
123                    .await
124                    .context(format!(
125                        "Failed to fetch branch head for '{}' on '{}'",
126                        branch, resolved_repo_slug
127                    ))?;
128                println!("{}", output::title("Build preview"));
129                output::kv("Package", &input.name);
130                output::kv(
131                    "Source",
132                    format!("{} ({})", resolved_repo_slug, resolved_provider),
133                );
134                output::kv("Ref", format!("branch {} @ {}", branch, commit));
135                output::print_transaction_table_without_size(&[
136                    output::TransactionRow::single_version(
137                        format!("{}/{}", resolved_provider, input.name),
138                        branch,
139                        disk_impact.net,
140                        disk_impact.download,
141                    ),
142                ]);
143            } else {
144                let release = if let Some(tag) = input.tag.as_deref() {
145                    self.provider_manager
146                        .get_release_by_tag(
147                            &resolved_repo_slug,
148                            tag,
149                            &resolved_provider,
150                            resolved_base_url.as_deref(),
151                        )
152                        .await
153                        .context(format!(
154                            "Failed to fetch release '{}' for '{}'",
155                            tag, resolved_repo_slug
156                        ))?
157                } else {
158                    self.provider_manager
159                        .get_latest_release(
160                            &resolved_repo_slug,
161                            &resolved_provider,
162                            &input.channel,
163                            resolved_base_url.as_deref(),
164                        )
165                        .await
166                        .context(format!(
167                            "Failed to fetch latest release for '{}'",
168                            resolved_repo_slug
169                        ))?
170                };
171                println!("{}", output::title("Build preview"));
172                output::kv("Package", &input.name);
173                output::kv(
174                    "Source",
175                    format!("{} ({})", resolved_repo_slug, resolved_provider),
176                );
177                output::kv("Ref", format!("release {} ({})", release.name, release.tag));
178                output::print_transaction_table_without_size(&[
179                    output::TransactionRow::single_version(
180                        format!("{}/{}", resolved_provider, input.name),
181                        &release.tag,
182                        disk_impact.net,
183                        disk_impact.download,
184                    ),
185                ]);
186            }
187
188            match input.build_profile {
189                Some(profile) => output::kv("Profile", format!("{:?}", profile)),
190                None => output::kv("Profile", "auto-detect at build time"),
191            }
192            output::kv("Desktop", if input.desktop { "yes" } else { "no" });
193            output::action_note("resolve only (no compile, no install, no metadata changes)");
194            return Ok(());
195        }
196
197        let disk_impact = self
198            .estimate_build_disk_impact(
199                &input,
200                &resolved_repo_slug,
201                &resolved_provider,
202                resolved_base_url.as_deref(),
203            )
204            .await;
205        let new_version = input
206            .branch
207            .as_deref()
208            .or(input.tag.as_deref())
209            .unwrap_or("latest");
210        output::print_transaction_table_without_size(&[output::TransactionRow::single_version(
211            format!("{}/{}", resolved_provider, input.name),
212            new_version,
213            disk_impact.net,
214            disk_impact.download,
215        )]);
216        output::confirm_or_cancel("Proceed with installation?", true)?;
217
218        let worker = BuildWorker::new(self.provider_manager, self.paths);
219        let mut build_line_callback =
220            Some(|line: &str| output::status_line(output::Status::Plan, "build", line));
221        let build_result = worker
222            .build(
223                BuildRequest {
224                    name: input.name.clone(),
225                    repo_slug: resolved_repo_slug.clone(),
226                    provider: resolved_provider.clone(),
227                    base_url: resolved_base_url.clone(),
228                    version_tag: input.tag,
229                    branch: input.branch,
230                    requested_profile: input.build_profile,
231                    script_action: BuildScriptAction::Install,
232                },
233                input.channel.clone(),
234                &mut build_line_callback,
235            )
236            .await?;
237
238        println!(
239            "{}",
240            output::title(format!(
241                "Built artifact: {} ({:?})",
242                build_result.artifact_path.display(),
243                build_result.profile
244            ))
245        );
246
247        let mut package = Package::with_defaults(
248            input.name,
249            resolved_repo_slug,
250            Filetype::Binary,
251            None,
252            None,
253            input.channel,
254            resolved_provider,
255            resolved_base_url,
256        );
257        package.install_type = InstallType::Build;
258        package.build_branch = build_result.branch.clone();
259        package.build_commit = build_result.commit.clone();
260
261        let mut install_operation = InstallOperation::new(
262            self.provider_manager,
263            self.package_storage,
264            self.paths,
265            self.trusted_keys.clone(),
266        )?;
267        let mut msg = Some(|_: &str| {});
268        let installed = install_operation
269            .install_local_artifact(
270                package,
271                &build_result.artifact_path,
272                build_result.version,
273                &input.desktop,
274                PackageTransactionContext::build(),
275                &mut msg,
276            )
277            .await?;
278
279        println!(
280            "{}",
281            output::success(format!("Build install complete for '{}'.", installed.name))
282        );
283
284        Ok(())
285    }
286
287    async fn estimate_build_disk_impact(
288        &self,
289        input: &BuildCommandInput,
290        repo_slug: &str,
291        provider: &Provider,
292        base_url: Option<&str>,
293    ) -> DiskImpact {
294        if input.branch.is_some() {
295            return DiskImpact::unknown();
296        }
297
298        let release = if let Some(tag) = input.tag.as_deref() {
299            self.provider_manager
300                .get_release_by_tag(repo_slug, tag, provider, base_url)
301                .await
302        } else {
303            self.provider_manager
304                .get_latest_release(repo_slug, provider, &input.channel, base_url)
305                .await
306        };
307
308        let Ok(release) = release else {
309            return DiskImpact::unknown();
310        };
311
312        let source_size = release
313            .assets
314            .iter()
315            .find(|asset| asset.name.starts_with("source."))
316            .map(|asset| asset.size)
317            .unwrap_or(0);
318
319        install_impact_from_download(asset_size_estimate(source_size))
320    }
321}