Skip to main content

upstream_rs/application/operations/
build_operation.rs

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