1use crate::error::{BuildError, Error, Result};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use tracing::{debug, info, warn};
10
11#[derive(Debug, Clone)]
13pub struct BuildConfig {
14 pub source_url: String,
16 pub sha256: String,
18 pub name: String,
20 pub version: String,
22 pub prefix: PathBuf,
24 pub cellar: PathBuf,
26 pub build_deps: Vec<String>,
28 pub jobs: Option<usize>,
30 pub cc: Option<String>,
32 pub cxx: Option<String>,
34}
35
36impl BuildConfig {
37 pub fn get_jobs(&self) -> usize {
39 self.jobs.unwrap_or_else(num_cpus::get)
40 }
41}
42
43#[derive(Debug)]
45pub struct BuildResult {
46 pub install_path: PathBuf,
48}
49
50pub struct SourceBuilder {
52 config: BuildConfig,
53 work_dir: PathBuf,
54}
55
56impl SourceBuilder {
57 pub fn new(config: BuildConfig, work_dir: impl AsRef<Path>) -> Self {
59 Self {
60 config,
61 work_dir: work_dir.as_ref().to_path_buf(),
62 }
63 }
64
65 pub async fn build(&self) -> Result<BuildResult> {
67 info!("Building {} {} from source", self.config.name, self.config.version);
68
69 std::fs::create_dir_all(&self.work_dir)?;
71
72 let archive_path = self.download_source().await?;
74
75 let source_dir = self.extract_source(&archive_path)?;
77
78 let install_path = self.run_build(&source_dir)?;
80
81 Ok(BuildResult { install_path })
82 }
83
84 async fn download_source(&self) -> Result<PathBuf> {
86 use sha2::{Digest, Sha256};
87
88 let archive_name = self.config.source_url
89 .rsplit('/')
90 .next()
91 .unwrap_or("source.tar.gz");
92 let archive_path = self.work_dir.join(archive_name);
93
94 info!("Downloading source from {}", self.config.source_url);
95
96 let client = reqwest::Client::new();
98 let response = client.get(&self.config.source_url)
99 .send()
100 .await
101 .map_err(|e| Error::Build(BuildError::DownloadFailed {
102 package: self.config.name.clone(),
103 reason: format!("Failed to download: {}", e)
104 }))?;
105
106 if !response.status().is_success() {
107 return Err(Error::Build(BuildError::DownloadFailed {
108 package: self.config.name.clone(),
109 reason: format!("HTTP {}", response.status())
110 }));
111 }
112
113 let bytes = response.bytes()
114 .await
115 .map_err(|e| Error::Build(BuildError::DownloadFailed {
116 package: self.config.name.clone(),
117 reason: format!("Failed to read: {}", e)
118 }))?;
119
120 let mut hasher = Sha256::new();
122 hasher.update(&bytes);
123 let hash = format!("{:x}", hasher.finalize());
124
125 if hash != self.config.sha256 {
126 return Err(Error::Build(BuildError::DownloadFailed {
127 package: self.config.name.clone(),
128 reason: format!("Checksum mismatch: expected {}, got {}", self.config.sha256, hash)
129 }));
130 }
131
132 std::fs::write(&archive_path, &bytes)?;
133 debug!("Downloaded and verified source archive");
134
135 Ok(archive_path)
136 }
137
138 fn extract_source(&self, archive_path: &Path) -> Result<PathBuf> {
140 use flate2::read::GzDecoder;
141 use tar::Archive;
142
143 info!("Extracting source archive");
144
145 let file = std::fs::File::open(archive_path)?;
146 let decoder = GzDecoder::new(file);
147 let mut archive = Archive::new(decoder);
148
149 archive.unpack(&self.work_dir)?;
151
152 let expected_dir = format!("{}-{}", self.config.name, self.config.version);
154 let source_dir = self.work_dir.join(&expected_dir);
155
156 if source_dir.exists() {
157 return Ok(source_dir);
158 }
159
160 for entry in std::fs::read_dir(&self.work_dir)? {
162 let entry = entry?;
163 if entry.file_type()?.is_dir() {
164 let name = entry.file_name();
165 if name.to_string_lossy() != "." && name.to_string_lossy() != ".." {
166 return Ok(entry.path());
167 }
168 }
169 }
170
171 Err(Error::Build(BuildError::SourceDirectoryNotFound {
172 package: self.config.name.clone()
173 }))
174 }
175
176 fn run_build(&self, source_dir: &Path) -> Result<PathBuf> {
178 let install_path = self.config.cellar
179 .join(&self.config.name)
180 .join(&self.config.version);
181
182 info!("Building in {:?}", source_dir);
183 info!("Install path: {:?}", install_path);
184
185 std::fs::create_dir_all(&install_path)?;
187
188 if source_dir.join("CMakeLists.txt").exists() {
190 self.build_cmake(source_dir, &install_path)?;
191 } else if source_dir.join("configure").exists() {
192 self.build_autotools(source_dir, &install_path)?;
193 } else if source_dir.join("Makefile").exists() {
194 self.build_make(source_dir, &install_path)?;
195 } else if source_dir.join("meson.build").exists() {
196 self.build_meson(source_dir, &install_path)?;
197 } else if source_dir.join("Cargo.toml").exists() {
198 self.build_cargo(source_dir, &install_path)?;
199 } else {
200 return Err(Error::Build(BuildError::unknown_build_system(&self.config.name)));
201 }
202
203 Ok(install_path)
204 }
205
206 fn build_autotools(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
208 info!("Using autotools build system");
209
210 let mut configure_cmd = Command::new("./configure");
211 configure_cmd
212 .arg(format!("--prefix={}", install_path.display()))
213 .current_dir(source_dir)
214 .env("HOMEBREW_PREFIX", &self.config.prefix);
215
216 if let Some(cc) = &self.config.cc {
218 validate_compiler_path(cc)?;
219 configure_cmd.env("CC", cc);
220 }
221 if let Some(cxx) = &self.config.cxx {
222 validate_compiler_path(cxx)?;
223 configure_cmd.env("CXX", cxx);
224 }
225
226 let configure_status = configure_cmd.status()?;
227
228 if !configure_status.success() {
229 return Err(Error::Build(BuildError::configure_failed(&self.config.name)));
230 }
231
232 let mut make_cmd = Command::new("make");
234 make_cmd
235 .arg("-j")
236 .arg(self.config.get_jobs().to_string())
237 .current_dir(source_dir);
238
239 if let Some(cc) = &self.config.cc {
241 validate_compiler_path(cc)?;
242 make_cmd.env("CC", cc);
243 }
244 if let Some(cxx) = &self.config.cxx {
245 validate_compiler_path(cxx)?;
246 make_cmd.env("CXX", cxx);
247 }
248
249 let make_status = make_cmd.status()?;
250
251 if !make_status.success() {
252 return Err(Error::Build(BuildError::make_failed(&self.config.name)));
253 }
254
255 let install_status = Command::new("make")
258 .arg("install")
259 .arg("--")
260 .current_dir(source_dir)
261 .status()?;
262
263 if !install_status.success() {
264 return Err(Error::Build(BuildError::make_install_failed(&self.config.name)));
265 }
266
267 Ok(())
268 }
269
270 fn build_cmake(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
272 info!("Using CMake build system");
273
274 let build_dir = source_dir.join("build");
275 std::fs::create_dir_all(&build_dir)?;
276
277 let mut cmake_cmd = Command::new("cmake");
279 cmake_cmd
280 .arg("..")
281 .arg(format!("-DCMAKE_INSTALL_PREFIX={}", install_path.display()))
282 .arg("-DCMAKE_BUILD_TYPE=Release")
283 .current_dir(&build_dir);
284
285 if let Some(cc) = &self.config.cc {
287 validate_compiler_path(cc)?;
288 cmake_cmd.arg(format!("-DCMAKE_C_COMPILER={}", cc));
289 }
290 if let Some(cxx) = &self.config.cxx {
291 validate_compiler_path(cxx)?;
292 cmake_cmd.arg(format!("-DCMAKE_CXX_COMPILER={}", cxx));
293 }
294
295 let cmake_status = cmake_cmd.status()?;
296
297 if !cmake_status.success() {
298 return Err(Error::Build(BuildError::CmakeConfigureFailed {
299 package: self.config.name.clone()
300 }));
301 }
302
303 let build_status = Command::new("cmake")
305 .arg("--build")
306 .arg(".")
307 .arg("-j")
308 .arg(self.config.get_jobs().to_string())
309 .current_dir(&build_dir)
310 .status()?;
311
312 if !build_status.success() {
313 return Err(Error::Build(BuildError::CmakeBuildFailed {
314 package: self.config.name.clone()
315 }));
316 }
317
318 let install_status = Command::new("cmake")
320 .arg("--install")
321 .arg(".")
322 .current_dir(&build_dir)
323 .status()?;
324
325 if !install_status.success() {
326 return Err(Error::Build(BuildError::CmakeInstallFailed {
327 package: self.config.name.clone()
328 }));
329 }
330
331 Ok(())
332 }
333
334 fn build_make(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
336 info!("Using Makefile build system");
337
338 let mut make_cmd = Command::new("make");
340 make_cmd
341 .arg("-j")
342 .arg(self.config.get_jobs().to_string())
343 .current_dir(source_dir)
344 .env("PREFIX", install_path);
345
346 if let Some(cc) = &self.config.cc {
348 validate_compiler_path(cc)?;
349 make_cmd.env("CC", cc);
350 }
351 if let Some(cxx) = &self.config.cxx {
352 validate_compiler_path(cxx)?;
353 make_cmd.env("CXX", cxx);
354 }
355
356 let make_status = make_cmd.status()?;
357
358 if !make_status.success() {
359 return Err(Error::Build(BuildError::make_failed(&self.config.name)));
360 }
361
362 let install_status = Command::new("make")
364 .arg("install")
365 .arg(format!("PREFIX={}", install_path.display()))
366 .current_dir(source_dir)
367 .status()?;
368
369 if !install_status.success() {
370 return Err(Error::Build(BuildError::make_install_failed(&self.config.name)));
371 }
372
373 Ok(())
374 }
375
376 fn build_meson(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
378 info!("Using Meson build system");
379
380 let build_dir = source_dir.join("build");
381
382 let mut setup_cmd = Command::new("meson");
384 setup_cmd
385 .arg("setup")
386 .arg(&build_dir)
387 .arg(format!("--prefix={}", install_path.display()))
388 .current_dir(source_dir);
389
390 if let Some(cc) = &self.config.cc {
392 validate_compiler_path(cc)?;
393 setup_cmd.env("CC", cc);
394 }
395 if let Some(cxx) = &self.config.cxx {
396 validate_compiler_path(cxx)?;
397 setup_cmd.env("CXX", cxx);
398 }
399
400 let setup_status = setup_cmd.status()?;
401
402 if !setup_status.success() {
403 return Err(Error::Build(BuildError::MesonConfigureFailed {
404 package: self.config.name.clone()
405 }));
406 }
407
408 let compile_status = Command::new("meson")
410 .arg("compile")
411 .arg("-C")
412 .arg(&build_dir)
413 .arg("-j")
414 .arg(self.config.get_jobs().to_string())
415 .status()?;
416
417 if !compile_status.success() {
418 return Err(Error::Build(BuildError::MesonCompileFailed {
419 package: self.config.name.clone()
420 }));
421 }
422
423 let install_status = Command::new("meson")
425 .arg("install")
426 .arg("-C")
427 .arg(&build_dir)
428 .status()?;
429
430 if !install_status.success() {
431 return Err(Error::Build(BuildError::MesonInstallFailed {
432 package: self.config.name.clone()
433 }));
434 }
435
436 Ok(())
437 }
438
439 fn build_cargo(&self, source_dir: &Path, install_path: &Path) -> Result<()> {
441 info!("Using Cargo build system");
442
443 let build_status = Command::new("cargo")
445 .arg("build")
446 .arg("--release")
447 .arg("-j")
448 .arg(self.config.get_jobs().to_string())
449 .current_dir(source_dir)
450 .status()?;
451
452 if !build_status.success() {
453 return Err(Error::Build(BuildError::CargoBuildFailed {
454 package: self.config.name.clone()
455 }));
456 }
457
458 let bin_dir = install_path.join("bin");
460 std::fs::create_dir_all(&bin_dir)?;
461
462 let release_dir = source_dir.join("target/release");
463 if release_dir.exists() {
464 for entry in std::fs::read_dir(&release_dir)? {
465 let entry = entry?;
466 let path = entry.path();
467 if path.is_file() && is_executable(&path) {
468 let file_name = path.file_name().unwrap();
469 let name = file_name.to_string_lossy();
471 if !name.contains('.') && !name.starts_with("lib") {
472 let dest = bin_dir.join(file_name);
473 std::fs::copy(&path, &dest)?;
474 debug!("Installed binary: {:?}", dest);
475 }
476 }
477 }
478 }
479
480 Ok(())
481 }
482}
483
484fn is_executable(path: &Path) -> bool {
486 use std::os::unix::fs::PermissionsExt;
487 if let Ok(metadata) = path.metadata() {
488 let permissions = metadata.permissions();
489 permissions.mode() & 0o111 != 0
490 } else {
491 false
492 }
493}
494
495fn validate_compiler_path(path: &str) -> Result<()> {
499 if path.trim().is_empty() {
501 return Err(Error::Build(BuildError::CompilerValidationFailed {
502 reason: "Compiler path cannot be empty".to_string(),
503 }));
504 }
505
506 if path.contains("..") || path.contains(';') || path.contains('|') || path.contains('$') {
508 return Err(Error::Build(BuildError::CompilerValidationFailed {
509 reason: format!("Invalid compiler path '{}': contains suspicious characters", path),
510 }));
511 }
512
513 Ok(())
514}
515
516pub fn can_build_from_source(source_url: &Option<String>) -> bool {
518 source_url.is_some()
519}