1use crate::{
2 Result,
3 builder::{BuildOptions, BuildTarget},
4 error,
5 messages::{BuildMessage, MessageReporter},
6};
7use snafu::{OptionExt, ResultExt};
8use std::{
9 io::{BufRead, BufReader, Read},
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12 thread,
13};
14use tracing::debug;
15
16pub(crate) use cargo_metadata::Metadata;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22pub enum CargoVerbosity {
23 #[default]
25 Normal,
26
27 Verbose,
29
30 VeryVerbose,
32
33 ExtremelyVerbose,
35}
36
37impl CargoVerbosity {
38 pub(crate) fn from_count(count: u8) -> Self {
42 match count {
43 0 => Self::Normal,
44 1 => Self::Verbose,
45 2 => Self::VeryVerbose,
46 _ => Self::ExtremelyVerbose,
47 }
48 }
49}
50
51#[derive(Clone, Debug, Default)]
53pub(crate) struct CargoMetadataOptions {
54 pub no_deps: bool,
58
59 pub filter_platform: Option<String>,
62
63 pub features: Vec<String>,
66
67 pub all_features: bool,
70
71 pub no_default_features: bool,
74
75 pub offline: bool,
78
79 pub locked: bool,
82}
83
84impl From<&BuildOptions> for CargoMetadataOptions {
85 fn from(opts: &BuildOptions) -> Self {
86 Self {
87 no_deps: false,
88 filter_platform: opts.target.clone(),
89 features: opts.features.clone(),
90 all_features: opts.all_features,
91 no_default_features: opts.no_default_features,
92 offline: opts.offline,
93 locked: opts.locked,
94 }
95 }
96}
97
98pub(crate) trait CargoRunner: std::fmt::Debug + Send + Sync + 'static {
108 fn metadata(&self, source_dir: &Path, options: &CargoMetadataOptions) -> Result<Metadata>;
118
119 fn build(&self, source_dir: &Path, package: Option<&str>, options: &BuildOptions) -> Result<PathBuf>;
155}
156
157pub(crate) fn find_cargo(reporter: MessageReporter) -> Result<impl CargoRunner> {
159 let cargo_path = find_executable("cargo", "CARGO")?;
170 let rustup_path = find_executable("rustup", "RUSTUP").ok();
171
172 Ok(RealCargoRunner {
173 cargo_path,
174 rustup_path,
175 reporter,
176 })
177}
178
179#[derive(Debug, Clone)]
180struct RealCargoRunner {
181 cargo_path: PathBuf,
182 rustup_path: Option<PathBuf>,
183 reporter: MessageReporter,
184}
185
186impl CargoRunner for RealCargoRunner {
187 fn metadata(&self, source_dir: &Path, options: &CargoMetadataOptions) -> Result<Metadata> {
188 use snafu::ResultExt;
189
190 let mut cmd = cargo_metadata::MetadataCommand::new();
191 cmd.cargo_path(&self.cargo_path).current_dir(source_dir);
192
193 if options.no_deps {
195 cmd.no_deps();
196 }
197
198 if options.all_features {
200 cmd.features(cargo_metadata::CargoOpt::AllFeatures);
201 } else {
202 if options.no_default_features {
203 cmd.features(cargo_metadata::CargoOpt::NoDefaultFeatures);
204 }
205 if !options.features.is_empty() {
206 cmd.features(cargo_metadata::CargoOpt::SomeFeatures(options.features.clone()));
207 }
208 }
209
210 let mut other_args = Vec::new();
212
213 let platform: Option<&str> = if options.no_deps {
216 options.filter_platform.as_deref()
218 } else {
219 Some(
222 options
223 .filter_platform
224 .as_deref()
225 .unwrap_or(build_context::TARGET),
226 )
227 };
228
229 if let Some(platform_str) = platform {
230 other_args.push("--filter-platform".to_string());
231 other_args.push(platform_str.to_string());
232 }
233
234 if options.offline {
235 other_args.push("--offline".to_string());
236 }
237
238 if options.locked {
239 other_args.push("--locked".to_string());
240 }
241
242 if !other_args.is_empty() {
243 cmd.other_options(other_args);
244 }
245
246 cmd.exec().with_context(|_| error::CargoMetadataSnafu {
247 cargo_path: self.cargo_path.clone(),
248 source_dir: source_dir.to_path_buf(),
249 })
250 }
251
252 fn build(&self, source_dir: &Path, package: Option<&str>, options: &BuildOptions) -> Result<PathBuf> {
253 if !source_dir.join("Cargo.toml").exists() {
255 return error::CargoTomlNotFoundSnafu {
256 source_dir: source_dir.to_path_buf(),
257 }
258 .fail();
259 }
260
261 self.reporter.report(|| BuildMessage::started(options));
262
263 let mut cmd = if let Some(toolchain) = &options.toolchain {
265 let rustup_path = self
267 .rustup_path
268 .as_ref()
269 .with_context(|| error::RustupNotFoundSnafu {
270 toolchain: toolchain.clone(),
271 })?;
272
273 let mut cmd = Command::new(rustup_path);
274 cmd.args(["run", toolchain, "cargo"]);
275 cmd
276 } else {
277 Command::new(&self.cargo_path)
278 };
279
280 cmd.arg("build");
282 cmd.current_dir(source_dir);
283 cmd.arg("--message-format=json");
284
285 if let Some(profile) = &options.profile {
287 cmd.args(["--profile", profile]);
288 } else {
289 cmd.arg("--release");
290 }
291
292 if let Some(pkg) = package {
294 cmd.args(["-p", pkg]);
295 }
296
297 if options.all_features {
299 cmd.arg("--all-features");
300 } else {
301 if options.no_default_features {
302 cmd.arg("--no-default-features");
303 }
304 if !options.features.is_empty() {
305 cmd.arg("--features");
306 cmd.arg(options.features.join(","));
307 }
308 }
309
310 if let Some(target) = &options.target {
312 cmd.args(["--target", target]);
313 }
314
315 match &options.build_target {
317 BuildTarget::DefaultBin => {
318 }
320 BuildTarget::Bin(name) => {
321 cmd.args(["--bin", name]);
322 }
323 BuildTarget::Example(name) => {
324 cmd.args(["--example", name]);
325 }
326 }
327
328 if options.offline {
330 cmd.arg("--offline");
331 }
332 if let Some(jobs) = options.jobs {
333 cmd.args(["-j", &jobs.to_string()]);
334 }
335 if options.ignore_rust_version {
336 cmd.arg("--ignore-rust-version");
337 }
338 if options.locked {
339 cmd.arg("--locked");
340 }
341
342 match options.cargo_verbosity {
344 CargoVerbosity::Normal => {}
345 CargoVerbosity::Verbose => {
346 cmd.arg("-v");
347 }
348 CargoVerbosity::VeryVerbose => {
349 cmd.arg("-vv");
350 }
351 CargoVerbosity::ExtremelyVerbose => {
352 cmd.arg("-vvv");
353 }
354 }
355
356 cmd.stdout(Stdio::piped());
358 cmd.stderr(Stdio::piped());
359
360 let mut child = cmd.spawn().context(error::CommandExecutionSnafu)?;
362
363 let stdout = child
365 .stdout
366 .take()
367 .with_context(|| error::BinaryNotFoundInOutputSnafu)?;
368 let stderr = child
369 .stderr
370 .take()
371 .with_context(|| error::BinaryNotFoundInOutputSnafu)?;
372
373 let stdout_reporter = self.reporter.clone();
375 let stderr_reporter = self.reporter.clone();
376
377 let build_target = options.build_target.clone();
379
380 let stdout_handle = thread::spawn(move || {
382 debug!("stdout parser thread starting");
383 let reader = BufReader::new(stdout);
384 let mut binary_path = None;
385
386 for line_result in reader.lines() {
387 let line = match line_result {
388 Ok(l) => l,
389 Err(_) => break,
390 };
391
392 if let Ok(cargo_msg) = serde_json::from_str::<cargo_metadata::Message>(&line) {
393 stdout_reporter.report(|| BuildMessage::cargo_message(cargo_msg.clone()));
394
395 if let cargo_metadata::Message::CompilerArtifact(artifact) = &cargo_msg {
396 let kinds = &artifact.target.kind;
397 let name = &artifact.target.name;
398
399 let matches = match &build_target {
400 BuildTarget::DefaultBin => {
401 kinds.iter().any(|k| *k == cargo_metadata::TargetKind::Bin)
402 }
403 BuildTarget::Bin(bin_name) => {
404 kinds.iter().any(|k| *k == cargo_metadata::TargetKind::Bin)
405 && name == bin_name
406 }
407 BuildTarget::Example(ex_name) => {
408 kinds.iter().any(|k| *k == cargo_metadata::TargetKind::Example)
409 && name == ex_name
410 }
411 };
412
413 if matches {
414 if let Some(exe) = &artifact.executable {
415 binary_path = Some(exe.clone().into_std_path_buf());
416 }
417 }
418 }
419 }
420 }
421
422 debug!("stdout parser thread exiting");
423 binary_path
424 });
425
426 let stderr_handle = thread::spawn(move || {
428 debug!("stderr reader thread starting");
429 let mut reader = BufReader::new(stderr);
430 let mut buffer = [0u8; 4096];
431
432 loop {
433 match reader.read(&mut buffer) {
434 Ok(0) | Err(_) => break,
435 Ok(n) => {
436 let chunk = buffer[..n].to_vec();
437 stderr_reporter.report(|| BuildMessage::cargo_stderr(chunk));
438 }
439 }
440 }
441
442 debug!("stderr reader thread exiting");
443 });
444
445 let status = child.wait().context(error::CommandExecutionSnafu)?;
447
448 let binary_path = stdout_handle.join().expect("stdout thread panicked");
450 stderr_handle.join().expect("stderr thread panicked");
451
452 if !status.success() {
453 return error::CargoBuildFailedSnafu {
454 exit_code: status.code(),
455 }
456 .fail();
457 }
458
459 match binary_path {
460 Some(path) => {
461 self.reporter.report(|| BuildMessage::completed(&path));
462 Ok(path)
463 }
464 None => error::BinaryNotFoundInOutputSnafu.fail(),
465 }
466 }
467}
468
469fn find_executable(name: &str, env_var: &str) -> Result<PathBuf> {
471 if let Ok(path) = std::env::var(env_var) {
473 let path = PathBuf::from(path);
474 if path.exists() {
475 return Ok(path);
476 }
477 }
478
479 if let Ok(path) = which::which(name) {
481 return Ok(path);
482 }
483
484 let cargo_home = std::env::var("CARGO_HOME")
486 .ok()
487 .map(PathBuf::from)
488 .or_else(|| home::cargo_home().ok());
489
490 if let Some(cargo_home) = cargo_home {
491 let path = cargo_home.join("bin").join(name);
492 if path.exists() {
493 return Ok(path);
494 }
495 }
496
497 error::ExecutableNotFoundSnafu {
498 name: name.to_string(),
499 }
500 .fail()
501}
502
503#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::{builder::BuildTarget, testdata::CrateTestCase};
513
514 fn cgx_project_root() -> PathBuf {
516 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
518 .parent()
519 .expect("cgx-core should have a parent directory (workspace root)")
520 .to_path_buf()
521 }
522
523 #[test]
524 fn find_cargo_succeeds() {
525 crate::logging::init_test_logging();
526
527 let _cargo = find_cargo(MessageReporter::null()).unwrap();
530 }
531
532 #[test]
533 fn metadata_reads_cgx_crate() {
534 crate::logging::init_test_logging();
535
536 let cargo = find_cargo(MessageReporter::null()).unwrap();
537 let cgx_root = cgx_project_root();
538
539 let metadata = cargo
540 .metadata(
541 &cgx_root,
542 &CargoMetadataOptions {
543 no_deps: true,
544 ..Default::default()
545 },
546 )
547 .unwrap();
548
549 let cgx_pkg = metadata
551 .packages
552 .iter()
553 .find(|p| p.name.as_str() == "cgx")
554 .unwrap();
555
556 assert_eq!(cgx_pkg.name.as_str(), "cgx");
557
558 assert!(!cgx_pkg.version.to_string().is_empty());
560
561 let has_bin = cgx_pkg
563 .targets
564 .iter()
565 .any(|t| t.kind.iter().any(|k| k.to_string() == "bin"));
566 assert!(has_bin, "cgx should have a binary target");
567 }
568
569 #[test]
570 fn build_compiles_cgx_in_tempdir() {
571 crate::logging::init_test_logging();
572
573 let cargo = find_cargo(MessageReporter::null()).unwrap();
574 let cgx_root = cgx_project_root();
575 let temp_dir = tempfile::tempdir().unwrap();
576
577 crate::helpers::copy_source_tree(&cgx_root, temp_dir.path()).unwrap();
579
580 assert!(
582 temp_dir.path().join("Cargo.toml").exists(),
583 "Cargo.toml should be copied"
584 );
585
586 let options = BuildOptions {
588 profile: Some("dev".to_string()),
589 build_target: BuildTarget::DefaultBin,
590 ..Default::default()
591 };
592
593 let binary_path = cargo.build(temp_dir.path(), Some("cgx"), &options).unwrap();
594
595 assert!(binary_path.exists(), "Binary should exist at {:?}", binary_path);
597 assert!(binary_path.is_file(), "Binary should be a file");
598
599 let file_name = binary_path.file_name().and_then(|n| n.to_str()).unwrap();
601 assert!(
602 file_name == "cgx" || file_name == "cgx.exe",
603 "Binary should be named cgx or cgx.exe, got {}",
604 file_name
605 );
606 }
607
608 #[test]
609 fn metadata_loads_all_testcases() {
610 crate::logging::init_test_logging();
611
612 let cargo = find_cargo(MessageReporter::null()).unwrap();
613
614 for testcase in CrateTestCase::all() {
615 let result = cargo.metadata(testcase.path(), &CargoMetadataOptions::default());
616
617 assert!(
618 result.is_ok(),
619 "Failed to load metadata for {}: {:?}",
620 testcase.name,
621 result.err()
622 );
623 }
624 }
625}