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}