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}