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 stderr: "(stderr was captured as messages)".to_string(),
456 }
457 .fail();
458 }
459
460 match binary_path {
461 Some(path) => {
462 self.reporter.report(|| BuildMessage::completed(&path));
463 Ok(path)
464 }
465 None => error::BinaryNotFoundInOutputSnafu.fail(),
466 }
467 }
468}
469
470fn find_executable(name: &str, env_var: &str) -> Result<PathBuf> {
472 if let Ok(path) = std::env::var(env_var) {
474 let path = PathBuf::from(path);
475 if path.exists() {
476 return Ok(path);
477 }
478 }
479
480 if let Ok(path) = which::which(name) {
482 return Ok(path);
483 }
484
485 let cargo_home = std::env::var("CARGO_HOME")
487 .ok()
488 .map(PathBuf::from)
489 .or_else(|| home::cargo_home().ok());
490
491 if let Some(cargo_home) = cargo_home {
492 let path = cargo_home.join("bin").join(name);
493 if path.exists() {
494 return Ok(path);
495 }
496 }
497
498 error::ExecutableNotFoundSnafu {
499 name: name.to_string(),
500 }
501 .fail()
502}
503
504#[cfg(test)]
511mod tests {
512 use super::*;
513 use crate::{builder::BuildTarget, testdata::CrateTestCase};
514
515 fn cgx_project_root() -> PathBuf {
517 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
519 .parent()
520 .expect("cgx-core should have a parent directory (workspace root)")
521 .to_path_buf()
522 }
523
524 #[test]
525 fn find_cargo_succeeds() {
526 crate::logging::init_test_logging();
527
528 let _cargo = find_cargo(MessageReporter::null()).unwrap();
531 }
532
533 #[test]
534 fn metadata_reads_cgx_crate() {
535 crate::logging::init_test_logging();
536
537 let cargo = find_cargo(MessageReporter::null()).unwrap();
538 let cgx_root = cgx_project_root();
539
540 let metadata = cargo
541 .metadata(
542 &cgx_root,
543 &CargoMetadataOptions {
544 no_deps: true,
545 ..Default::default()
546 },
547 )
548 .unwrap();
549
550 let cgx_pkg = metadata
552 .packages
553 .iter()
554 .find(|p| p.name.as_str() == "cgx")
555 .unwrap();
556
557 assert_eq!(cgx_pkg.name.as_str(), "cgx");
558
559 assert!(!cgx_pkg.version.to_string().is_empty());
561
562 let has_bin = cgx_pkg
564 .targets
565 .iter()
566 .any(|t| t.kind.iter().any(|k| k.to_string() == "bin"));
567 assert!(has_bin, "cgx should have a binary target");
568 }
569
570 #[test]
571 fn build_compiles_cgx_in_tempdir() {
572 crate::logging::init_test_logging();
573
574 let cargo = find_cargo(MessageReporter::null()).unwrap();
575 let cgx_root = cgx_project_root();
576 let temp_dir = tempfile::tempdir().unwrap();
577
578 crate::helpers::copy_source_tree(&cgx_root, temp_dir.path()).unwrap();
580
581 assert!(
583 temp_dir.path().join("Cargo.toml").exists(),
584 "Cargo.toml should be copied"
585 );
586
587 let options = BuildOptions {
589 profile: Some("dev".to_string()),
590 build_target: BuildTarget::DefaultBin,
591 ..Default::default()
592 };
593
594 let binary_path = cargo.build(temp_dir.path(), Some("cgx"), &options).unwrap();
595
596 assert!(binary_path.exists(), "Binary should exist at {:?}", binary_path);
598 assert!(binary_path.is_file(), "Binary should be a file");
599
600 let file_name = binary_path.file_name().and_then(|n| n.to_str()).unwrap();
602 assert!(
603 file_name == "cgx" || file_name == "cgx.exe",
604 "Binary should be named cgx or cgx.exe, got {}",
605 file_name
606 );
607 }
608
609 #[test]
610 fn metadata_loads_all_testcases() {
611 crate::logging::init_test_logging();
612
613 let cargo = find_cargo(MessageReporter::null()).unwrap();
614
615 for testcase in CrateTestCase::all() {
616 let result = cargo.metadata(testcase.path(), &CargoMetadataOptions::default());
617
618 assert!(
619 result.is_ok(),
620 "Failed to load metadata for {}: {:?}",
621 testcase.name,
622 result.err()
623 );
624 }
625 }
626}