Skip to main content

cgx_core/
cargo.rs

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/// Verbosity level for cargo build operations.
19///
20/// Maps to cargo's `-v` flags for controlling build output verbosity.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
22pub enum CargoVerbosity {
23    /// Normal cargo output (no verbosity flags).
24    #[default]
25    Normal,
26
27    /// Verbose output (corresponds to `-v`).
28    Verbose,
29
30    /// Very verbose output (corresponds to `-vv`).
31    VeryVerbose,
32
33    /// Extremely verbose output including build.rs output (corresponds to `-vvv`).
34    ExtremelyVerbose,
35}
36
37impl CargoVerbosity {
38    /// Construct a [`CargoVerbosity`] from a verbosity counter.
39    ///
40    /// The counter typically comes from CLI arguments where `-v` can be repeated.
41    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/// Options for controlling cargo metadata invocation.
52#[derive(Clone, Debug, Default)]
53pub(crate) struct CargoMetadataOptions {
54    /// Exclude dependency information from metadata output.
55    /// Corresponds to `--no-deps` flag.
56    /// Default: false (dependencies are included by default)
57    pub no_deps: bool,
58
59    /// Only include dependencies for the specified target platform.
60    /// Corresponds to `--filter-platform TARGET` flag.
61    pub filter_platform: Option<String>,
62
63    /// Space or comma separated list of features to activate.
64    /// Corresponds to `--features` flag.
65    pub features: Vec<String>,
66
67    /// Activate all available features.
68    /// Corresponds to `--all-features` flag.
69    pub all_features: bool,
70
71    /// Do not activate the `default` feature.
72    /// Corresponds to `--no-default-features` flag.
73    pub no_default_features: bool,
74
75    /// Run without accessing the network.
76    /// Corresponds to `--offline` flag.
77    pub offline: bool,
78
79    /// Require Cargo.lock is up to date.
80    /// Corresponds to `--locked` flag.
81    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
98/// Rust wrapper around shelling out to `cargo` for building and running Rust projects.
99///
100/// Much as it pains me, sometimes we must shell out to `cargo` to do things.  That's ugly,
101/// error-prone, and worst of all inelegant.  But it's also the only way to get certain things
102/// done.
103///
104/// This type is mainly concerened with the surprisingly complex task of figuring out where `cargo`
105/// is and how to invoke it, and secondarily with constructing its command lines and parsing the
106/// resulting output.
107pub(crate) trait CargoRunner: std::fmt::Debug + Send + Sync + 'static {
108    /// Get cargo metadata for a source directory.
109    ///
110    /// Executes `cargo metadata` on the specified directory and returns the
111    /// parsed metadata including workspace members, packages, and targets.
112    ///
113    /// # Arguments
114    ///
115    /// * `source_dir` - Path to directory containing Cargo.toml
116    /// * `options` - Options controlling metadata invocation (deps, features, platform, etc.)
117    fn metadata(&self, source_dir: &Path, options: &CargoMetadataOptions) -> Result<Metadata>;
118
119    /// Build a binary from source.
120    ///
121    /// Executes cargo build with specified options and returns the absolute path
122    /// to the compiled binary, determined by parsing `--message-format=json` output.
123    ///
124    /// It is assumed that either the only crate in the workspace is a binary, or that the crate
125    /// `package` has a binary or example matching `options.build_target`.
126    ///
127    /// # Arguments
128    ///
129    /// * `source_dir` - Directory containing Cargo.toml
130    /// * `package` - Package name for `-p` flag (required for multi-package workspaces)
131    /// * `options` - Build configuration
132    ///
133    /// # Toolchain Handling
134    ///
135    /// If `options.toolchain` is specified:
136    /// - Requires rustup (errors if unavailable)
137    /// - Invokes via `rustup run {toolchain} cargo build ...`
138    /// - This works regardless of whether cargo is a rustup proxy
139    ///
140    /// # Binary Location
141    ///
142    /// Uses `--message-format=json` to parse compiler artifacts and find the
143    /// executable path from "compiler-artifact" messages. This handles:
144    /// - Cross-compilation: target/{triple}/{profile}/...
145    /// - Examples: target/{profile}/examples/...
146    /// - Platform extensions: .exe on Windows
147    ///
148    /// # Errors
149    ///
150    /// - Cargo.toml not found in `source_dir`
151    /// - Toolchain specified but rustup not found
152    /// - Cargo build command fails
153    /// - Expected binary not found in cargo's JSON output
154    fn build(&self, source_dir: &Path, package: Option<&str>, options: &BuildOptions) -> Result<PathBuf>;
155}
156
157/// Locate cargo and construct a runner instance that will use it.
158pub(crate) fn find_cargo(reporter: MessageReporter) -> Result<impl CargoRunner> {
159    // Locate cargo and rustup executables.
160    //
161    // Searches for cargo in priority order:
162    // 1. `CARGO` environment variable (cargo's own convention)
163    // 2. `cargo` in PATH (via `which` crate)
164    // 3. `$CARGO_HOME/bin/cargo` where CARGO_HOME defaults to ~/.cargo
165    //
166    // Also searches for rustup (needed for `rustup run {toolchain}`).
167    // Rustup not found is non-fatal - only errors when toolchain specified.
168
169    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        // Only exclude deps if explicitly requested
194        if options.no_deps {
195            cmd.no_deps();
196        }
197
198        // Handle feature flags
199        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        // Build other_options for flags that don't have dedicated MetadataCommand methods
211        let mut other_args = Vec::new();
212
213        // Always filter by platform when resolving dependencies to avoid getting
214        // deps for all platforms mixed together. Default to current platform if not specified.
215        let platform: Option<&str> = if options.no_deps {
216            // When not resolving deps, platform filtering doesn't matter
217            options.filter_platform.as_deref()
218        } else {
219            // When resolving deps, MUST filter by platform
220            // Default to current platform if not specified
221            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        // Verify Cargo.toml exists
254        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        // Build the command
264        let mut cmd = if let Some(toolchain) = &options.toolchain {
265            // If toolchain is specified, we need rustup
266            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        // Add cargo build command and flags
281        cmd.arg("build");
282        cmd.current_dir(source_dir);
283        cmd.arg("--message-format=json");
284
285        // Profile (default to release)
286        if let Some(profile) = &options.profile {
287            cmd.args(["--profile", profile]);
288        } else {
289            cmd.arg("--release");
290        }
291
292        // Package selection for workspaces
293        if let Some(pkg) = package {
294            cmd.args(["-p", pkg]);
295        }
296
297        // Features
298        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        // Target triple for cross-compilation
311        if let Some(target) = &options.target {
312            cmd.args(["--target", target]);
313        }
314
315        // Build target (bin/example)
316        match &options.build_target {
317            BuildTarget::DefaultBin => {
318                // No specific flag needed, cargo will build the default binary
319            }
320            BuildTarget::Bin(name) => {
321                cmd.args(["--bin", name]);
322            }
323            BuildTarget::Example(name) => {
324                cmd.args(["--example", name]);
325            }
326        }
327
328        // Other flags
329        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        // Verbosity flags
343        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        // Configure pipes for streaming
357        cmd.stdout(Stdio::piped());
358        cmd.stderr(Stdio::piped());
359
360        // Spawn the process
361        let mut child = cmd.spawn().context(error::CommandExecutionSnafu)?;
362
363        // Take ownership of stdout and stderr pipes
364        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        // Clone reporter for threads
374        let stdout_reporter = self.reporter.clone();
375        let stderr_reporter = self.reporter.clone();
376
377        // Clone build target for stdout thread
378        let build_target = options.build_target.clone();
379
380        // Spawn stdout parsing thread
381        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        // Spawn stderr chunk reading thread
427        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        // Wait for process completion
446        let status = child.wait().context(error::CommandExecutionSnafu)?;
447
448        // Join both threads after wait() returns
449        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
469/// Find an executable by name, checking environment variable, PATH, and default locations.
470fn find_executable(name: &str, env_var: &str) -> Result<PathBuf> {
471    // Check environment variable
472    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    // Check PATH using `which` crate
480    if let Ok(path) = which::which(name) {
481        return Ok(path);
482    }
483
484    // Check $CARGO_HOME/bin/{name}
485    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/// Testing a wrapper around `cargo` thoroughly is out of the scope of simple unit tests, however
504/// we at least need to verify basic functionality and correctness.
505///
506/// By definition, if these tests are running, `cargo` must be present, so we've made some tests
507/// that operate on this project itself as test data.  Of course this isn't adequate coverage for
508/// all various scenarios, but it's better than nothing.
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::{builder::BuildTarget, testdata::CrateTestCase};
513
514    /// Get the path to the cgx workspace root directory.
515    fn cgx_project_root() -> PathBuf {
516        // CARGO_MANIFEST_DIR points to cgx-core, we need the workspace root (parent directory)
517        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        // This test verifies that we can locate cargo on the system.
528        // This should always succeed since cargo is required to run the tests.
529        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        // Verify we found the cgx package
550        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        // Verify version is valid semver
559        assert!(!cgx_pkg.version.to_string().is_empty());
560
561        // Verify we have at least one binary target
562        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        // Copy source to temp directory
578        crate::helpers::copy_source_tree(&cgx_root, temp_dir.path()).unwrap();
579
580        // Verify Cargo.toml was copied
581        assert!(
582            temp_dir.path().join("Cargo.toml").exists(),
583            "Cargo.toml should be copied"
584        );
585
586        // Build in dev mode (faster than release)
587        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        // Verify binary exists and is a file
596        assert!(binary_path.exists(), "Binary should exist at {:?}", binary_path);
597        assert!(binary_path.is_file(), "Binary should be a file");
598
599        // Verify it's named correctly (cgx or cgx.exe on Windows)
600        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}