Skip to main content

cgx_core/
builder.rs

1use std::{borrow::Cow, path::PathBuf, sync::Arc};
2
3use cargo_metadata::Target;
4use snafu::ResultExt;
5
6use crate::{
7    Result,
8    cache::Cache,
9    cargo::{CargoMetadataOptions, CargoRunner, Metadata},
10    config::Config,
11    crate_resolver::ResolvedSource,
12    cratespec::CrateSpec,
13    downloader::DownloadedCrate,
14    error,
15    target::TargetTriple,
16};
17
18/// Which executable within a crate to build.
19#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
20pub enum BuildTarget {
21    /// No specific target was requested. cgx builds Cargo's default binary target, using
22    /// `default-run` when the package defines one.
23    #[default]
24    DefaultBin,
25
26    /// A specific binary target to build.
27    Bin(String),
28
29    /// A specific example target to build.
30    Example(String),
31}
32
33/// Build-related overrides (presumed to come from CLI arguments) that are merged with config
34/// settings to produce final [`BuildOptions`] for building a crate.
35#[derive(Clone, Debug, Default)]
36pub struct BuildOverrides {
37    /// Features to activate
38    ///
39    /// `None` means `--features` was not given; `Some(vec![])` means it was given but empty
40    pub features: Option<Vec<String>>,
41
42    /// Activate all available features
43    pub all_features: bool,
44
45    /// Do not activate the `default` feature
46    pub no_default_features: bool,
47
48    /// Build profile
49    pub profile: Option<String>,
50
51    /// Target triple for cross-compilation
52    pub target: Option<String>,
53
54    /// Number of parallel jobs for compilation
55    pub jobs: Option<usize>,
56
57    /// Ignore `rust-version` specification in packages
58    pub ignore_rust_version: bool,
59
60    /// Which executable within the crate to build
61    pub target_selection: BuildTarget,
62
63    /// Rust toolchain override
64    pub toolchain: Option<String>,
65}
66
67/// Options that control how a crate is built.
68///
69/// These options map to flags passed to `cargo build`.
70/// They are orthogonal to the crate identity and location (see [`crate::CrateSpec`]),
71/// focusing instead on build configuration, feature selection, and compilation settings.
72///
73/// There is a somewhat blurry line between [`Config`] and [`BuildOptions`]; the intention is that
74/// [`Config`] contains all parameters that can either be set via config file or overridden via CLI
75/// argument, and encompasses parameters regulating all aspects of `cgx` behavior.  By contrast,
76/// `BuildOptions` is specifically capturing options that effect how a crate is built; to a first
77/// approximation you can think of this as a Rust struct that represents the args passed to `cargo
78/// build` when building the user's desired crate from source.
79///
80/// There are some CLI args that are inherently crate-specifie (such as `--features`), which are
81/// not present in [`Config`] and cannot be set in the config files; those naturally are captured
82/// as part of `BuildOptions`.  However there are others like `--locked` and `--target` that can be
83/// overridden for all crates via config file or applied to a specific invocation via CLI arg;
84/// those are present here because they directly influence the command line passed to `cargo
85/// build`, although they get populated from the [`Config`] struct which reflects settings in the
86/// config files and any overrides of those settings that the user applied at the CLI.
87///
88/// Another way to reason about whether something should be in this struct or in [`Config`] is that
89/// this struct implements [`Hash`], and that hash is used a cache key for caching build artifacts.
90/// So if a field has a different value, should that invalidate any cached build artifacts and
91/// cause a rebuild?  If so, then it probably belongs here.  If not, then it probably belongs in
92/// [`Config`].  A good example of this is the `verbose` command, which literally translates into a
93/// `cargo` command but is NOT considered a build option, because why would we rebuild a crate just
94/// because the verbosity level is different?
95#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
96pub struct BuildOptions {
97    /// Features to activate (corresponds to `--features`).
98    pub(crate) features: Vec<String>,
99
100    /// Activate all available features (corresponds to `--all-features`).
101    pub(crate) all_features: bool,
102
103    /// Do not activate the `default` feature (corresponds to `--no-default-features`).
104    pub(crate) no_default_features: bool,
105
106    /// Build profile to use (corresponds to `--profile`).
107    ///
108    /// When `None`, the default release profile is used.
109    /// Use `Some("dev")` for debug builds.
110    pub(crate) profile: Option<String>,
111
112    /// Target triple for cross-compilation (corresponds to `--target`).
113    pub(crate) target: Option<TargetTriple>,
114
115    /// Require that `Cargo.lock` remains unchanged (corresponds to `--locked`).
116    pub(crate) locked: bool,
117
118    /// Run without accessing the network (corresponds to `--offline`).
119    pub(crate) offline: bool,
120
121    /// Number of parallel jobs for compilation (corresponds to `-j`/`--jobs`).
122    ///
123    /// When `None`, cargo uses its default (number of CPUs).
124    pub(crate) jobs: Option<usize>,
125
126    /// Ignore `rust-version` specification in packages (corresponds to `--ignore-rust-version`).
127    pub(crate) ignore_rust_version: bool,
128
129    /// Which executable within the crate to build.
130    pub(crate) build_target: BuildTarget,
131
132    /// Rust toolchain override to use for this build (e.g., "nightly", "1.70.0", "stable").
133    ///
134    /// When set, Cargo is run through `rustup run <toolchain>`.
135    pub(crate) toolchain: Option<String>,
136}
137
138impl Default for BuildOptions {
139    fn default() -> Self {
140        Self {
141            features: Vec::new(),
142            all_features: false,
143            no_default_features: false,
144            profile: None,
145            target: None,
146            locked: true,
147            offline: false,
148            jobs: None,
149            ignore_rust_version: false,
150            build_target: BuildTarget::default(),
151            toolchain: None,
152        }
153    }
154}
155
156impl BuildOptions {
157    /// Features to activate.
158    pub fn features(&self) -> &[String] {
159        &self.features
160    }
161
162    /// Whether all available features are activated.
163    pub fn all_features(&self) -> bool {
164        self.all_features
165    }
166
167    /// Whether the default feature is disabled.
168    pub fn no_default_features(&self) -> bool {
169        self.no_default_features
170    }
171
172    /// The build profile to use.
173    pub fn profile(&self) -> Option<&str> {
174        self.profile.as_deref()
175    }
176
177    /// The explicit target triple for cross-compilation, if one was requested.
178    pub fn target(&self) -> Option<&str> {
179        self.target.as_ref().map(TargetTriple::as_str)
180    }
181
182    /// Whether `Cargo.lock` must remain unchanged.
183    pub fn locked(&self) -> bool {
184        self.locked
185    }
186
187    /// Whether Cargo should run without accessing the network.
188    pub fn offline(&self) -> bool {
189        self.offline
190    }
191
192    /// The requested number of parallel jobs for compilation.
193    pub fn jobs(&self) -> Option<usize> {
194        self.jobs
195    }
196
197    /// Whether to ignore package `rust-version` declarations.
198    pub fn ignore_rust_version(&self) -> bool {
199        self.ignore_rust_version
200    }
201
202    /// Which executable within the crate to build.
203    pub fn build_target(&self) -> &BuildTarget {
204        &self.build_target
205    }
206
207    /// The Rust toolchain override to use for this build.
208    pub fn toolchain(&self) -> Option<&str> {
209        self.toolchain.as_deref()
210    }
211
212    /// Merge build options from config and already-translated CLI overrides, with proper
213    /// precedence.
214    ///
215    /// Config-handled settings (`locked`, `offline`, `toolchain`) come from [`Config`], which
216    /// has already processed CLI overrides like `--locked`, `--unlocked`, `--frozen`, `--offline`.
217    ///
218    /// Crate-specific settings (features, profile, target, etc.) come from [`BuildOverrides`],
219    /// which the CLI front-end has already produced from the raw arguments (tokenizing
220    /// features, folding `--debug`, resolving `--bin`/`--example`).
221    pub fn load(config: &Config, overrides: &BuildOverrides) -> Result<Self> {
222        // The target string is deliberately NOT validated here: cargo accepts targets (and even
223        // target-spec JSON paths) that we cannot parse, so whatever the user passed is carried
224        // through verbatim.
225        let target = overrides.target.clone().map(TargetTriple::from_owned);
226
227        Ok(BuildOptions {
228            // These come from the config settings, `Config` will already apply any CLI overrides
229            locked: config.locked,
230            offline: config.offline,
231
232            // This can be set in the config file, but it can be overridden on the CLI
233            toolchain: overrides.toolchain.clone().or_else(|| config.toolchain.clone()),
234
235            // The rest of these come exclusively from the CLI overrides
236            features: overrides.features.clone().unwrap_or_default(),
237            all_features: overrides.all_features,
238            no_default_features: overrides.no_default_features,
239            profile: overrides.profile.clone(),
240            target,
241            jobs: overrides.jobs,
242            ignore_rust_version: overrides.ignore_rust_version,
243            build_target: overrides.target_selection.clone(),
244        })
245    }
246
247    /// Load the build options for a specific crate.
248    ///
249    /// This will respect the `[tools]` section in the cgx config TOML, if there is an entry for
250    /// this crate then the options specified for that crate will be applied unless they have been
251    /// overridden in `BuildOverrides`.
252    pub fn load_for_crate(
253        config: &Config,
254        overrides: &BuildOverrides,
255        crate_spec: &CrateSpec,
256    ) -> Result<Self> {
257        // Load the standard build options from the CLI/config files, without any crate-specific
258        // options.
259        let mut options = Self::load(config, overrides)?;
260
261        // Look up the tool-specific options for this crate, if any, and apply them if they are not
262        // overridden by the build overrides from the CLI.
263        //
264        // The `[tools]` settings that bear on building are the selected features and
265        // `default-features`. If features haven't been explicitly overridden on the command line but
266        // were specified in the config for this crate, use those. `default-features = false` disables
267        // default features the same as `--no-default-features`.
268        //
269        // Under `--all-features` neither setting is applied: cargo is invoked with `--all-features`
270        // alone, so they could not affect the build and would only perturb the build-cache key.
271        if !options.all_features {
272            if let Some(crate_name) = crate_spec.configured_tool_name() {
273                if let Some(tool_config) = config.tools.get(crate_name) {
274                    if overrides.features.is_none() {
275                        if let Some(features) = tool_config.features() {
276                            options.features = features.to_vec();
277                        }
278                    }
279
280                    if !tool_config.default_features() {
281                        options.no_default_features = true;
282                    }
283                }
284            }
285        }
286
287        Ok(options)
288    }
289
290    /// The resolved Rust target triple this build targets: the explicit `--target`, or cgx's own
291    /// host triple ([`build_context::TARGET`]) when none was given.
292    pub(crate) fn target_platform(&self) -> &TargetTriple {
293        match &self.target {
294            Some(target) => target,
295            None => TargetTriple::host(),
296        }
297    }
298}
299
300pub trait CrateBuilder {
301    /// List the targets in the given crate that can be built using [`Self::build`].
302    ///
303    /// [`Self::build`] can build any bin or example target in the crate.
304    ///
305    /// Returns a tuple of:
306    /// - The package's explicit `default-run` target, if any
307    /// - A list of all binary targets
308    /// - A list of all example targets
309    fn list_targets(
310        &self,
311        krate: &DownloadedCrate,
312        options: &BuildOptions,
313    ) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)>;
314
315    /// Produce a compiled binary from the given crate, using the specified build options.
316    ///
317    /// Builds from registry and git sources can be cached. Local directory builds run directly from
318    /// the local source tree.  So this may or may not actually compile anything,
319    /// depending on the crate source, the state of the cache, and the config.
320    ///
321    /// Returns the full path to the compiled binary and the concrete [`BuildTarget`] that was built
322    /// (a `DefaultBin` request is resolved to the actual `Bin`/`Example` here, even on a cache
323    /// hit).
324    fn build(&self, krate: &DownloadedCrate, options: &BuildOptions) -> Result<(PathBuf, BuildTarget)>;
325}
326
327pub(crate) fn create_builder(
328    config: Config,
329    cache: Cache,
330    cargo_runner: Arc<dyn CargoRunner>,
331) -> impl CrateBuilder {
332    RealCrateBuilder {
333        config,
334        cache,
335        cargo_runner,
336    }
337}
338
339/// Builder which is responsible for compiling a specific binary target in a crate, from source.
340struct RealCrateBuilder {
341    config: Config,
342    cache: Cache,
343    cargo_runner: Arc<dyn CargoRunner>,
344}
345
346impl CrateBuilder for RealCrateBuilder {
347    fn list_targets(
348        &self,
349        krate: &DownloadedCrate,
350        options: &BuildOptions,
351    ) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)> {
352        let metadata = self
353            .cargo_runner
354            .metadata(&krate.crate_path, &CargoMetadataOptions::from(options))?;
355
356        Self::list_targets_internal(krate, &metadata)
357    }
358
359    fn build(&self, krate: &DownloadedCrate, options: &BuildOptions) -> Result<(PathBuf, BuildTarget)> {
360        // Gather metadata about the crate in its current source form.
361        // The act of building will re-gather the metadata after the build, but this is needed to
362        // resolve target and package information before building.
363        let metadata = self
364            .cargo_runner
365            .metadata(&krate.crate_path, &CargoMetadataOptions::from(options))?;
366
367        // If the user has not specified an explicit binary target, attempt to resolve it now.
368        // If the crate has multiple (or no) binary targets, this is the time to fail fast.
369        // Plus the cache needs to know the actual binary name, not DefaultBin.
370        let options: Cow<'_, BuildOptions> = if matches!(options.build_target, BuildTarget::DefaultBin) {
371            Cow::Owned(BuildOptions {
372                build_target: Self::resolve_binary_target(krate, options, &metadata)?,
373                ..options.clone()
374            })
375        } else {
376            Cow::Borrowed(options)
377        };
378
379        // The build target (ie, which binary or example to run) is now known, whether resolved
380        // just above or supplied explicitly. Report it alongside the binary, including on a cache
381        // hit.
382        let built_target = options.build_target.clone();
383
384        // Crates resolved from local sources are, by definition, local.  Not only does that mean
385        // that they are on a local filesystem (and presumably fast to access), but it also means
386        // that their source contents are mutable.  Even if we wanted to cache them, we would need
387        // a way to detect if any changes had occurred since the last build (basically what `cargo
388        // build` does), and that doesn't seem worth it.  So local crates are always built directly
389        // from their sources, and never cached
390        if matches!(krate.resolved.source, ResolvedSource::LocalDir { .. }) {
391            let (binary_path, _sbom) = self.build_uncached(krate, options.as_ref(), &metadata)?;
392            return Ok((binary_path, built_target));
393        }
394
395        let binary_path = self
396            .cache
397            .get_or_build_binary(&krate.resolved, options.as_ref(), || {
398                self.build_uncached(krate, options.as_ref(), &metadata)
399            })?;
400        Ok((binary_path, built_target))
401    }
402}
403
404impl RealCrateBuilder {
405    /// List the targets in the given crate that can be build using [`Self::build`].
406    ///
407    /// Unlike the public [`CrateBuilder::list_targets`], this internal version takes the cargo
408    /// metadata as an argument, allowing it to be reused and avoid redundant metadata queries.
409    fn list_targets_internal(
410        krate: &DownloadedCrate,
411        metadata: &Metadata,
412    ) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)> {
413        // Find the crate package in metadata
414        let package = metadata
415            .packages
416            .iter()
417            .find(|p| p.name.as_str() == krate.resolved.name)
418            .ok_or_else(|| {
419                error::PackageNotFoundInWorkspaceSnafu {
420                    name: krate.resolved.name.clone(),
421                    available: metadata
422                        .packages
423                        .iter()
424                        .map(|p| p.name.to_string())
425                        .collect::<Vec<_>>(),
426                }
427                .build()
428            })?;
429
430        // Get all bin and example targets in the package, since those are the only kinds that we
431        // support running with `cgx`
432        let bin_targets: Vec<_> = package
433            .targets
434            .iter()
435            .filter(|t| {
436                t.kind
437                    .iter()
438                    .any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
439            })
440            .cloned()
441            .collect();
442        let example_targets: Vec<_> = package
443            .targets
444            .iter()
445            .filter(|t| {
446                t.kind
447                    .iter()
448                    .any(|k| matches!(k, cargo_metadata::TargetKind::Example))
449            })
450            .cloned()
451            .collect();
452
453        // If an explicit bin was specified in `default_run`, use that as the default target
454        let default = package.default_run.as_ref().and_then(|default_run| {
455            bin_targets
456                .iter()
457                .find(|t| t.name == default_run.as_str())
458                .cloned()
459        });
460
461        Ok((default, bin_targets, example_targets))
462    }
463
464    /// Resolve [`BuildTarget`] to an actual binary name before building or caching.
465    ///
466    /// This not only validates that, if an explicit target was specified, that it actually exists,
467    /// but also resolves the `DefaultBin` case to a specific binary name.
468    ///
469    /// Returns an explicit [`BuildTarget`] guaranteed not to be `DefaultBin`, or an error if
470    /// resolution fails.
471    fn resolve_binary_target(
472        krate: &DownloadedCrate,
473        options: &BuildOptions,
474        metadata: &Metadata,
475    ) -> Result<BuildTarget> {
476        let (default, bins, examples) = Self::list_targets_internal(krate, metadata)?;
477
478        // If no explicit target was specified but the crate package has `default_run`, use that
479        let build_target = if matches!(options.build_target, BuildTarget::DefaultBin) {
480            if let Some(default) = default {
481                BuildTarget::Bin(default.name.clone())
482            } else {
483                BuildTarget::DefaultBin
484            }
485        } else {
486            options.build_target.clone()
487        };
488
489        // Select a specific build target.  There are a few possible permutations here:
490        // - The user didn't explicitly ask for a particular target, but the package has a
491        // `default_run`, so act like the user specified that explicitly and proceed further.
492        // - The user specified an explicit bin or example; just need to verify that it's in the
493        // runnable targets, fail if it's not, then we're good
494        // - The user didn't explicitly ask for a particular target, and the package does not have
495        // a `default_run`.  If the package has exactly one binary, use that.  If it has no
496        // binaries, fail.  If it has multiple binaries, fail.
497
498        match build_target {
499            BuildTarget::DefaultBin => {
500                // No explicit target, no default_run - must have exactly one binary
501                match bins.len() {
502                    0 => {
503                        // No binary targets - this will fail later when cargo tries to build
504                        error::NoPackageBinariesSnafu {
505                            krate: krate.resolved.name.clone(),
506                        }
507                        .fail()
508                    }
509                    1 => {
510                        // Exactly one binary, use it
511                        Ok(BuildTarget::Bin(bins[0].name.clone()))
512                    }
513                    _ => {
514                        // Multiple binaries - ambiguous
515                        error::AmbiguousBinaryTargetSnafu {
516                            package: krate.resolved.name.clone(),
517                            available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
518                        }
519                        .fail()
520                    }
521                }
522            }
523            BuildTarget::Bin(ref name) => {
524                // Explicit binary target - verify it exists
525                if bins.iter().any(|t| t.name == *name) {
526                    Ok(build_target)
527                } else {
528                    error::RunnableTargetNotFoundSnafu {
529                        kind: "binary",
530                        package: krate.resolved.name.clone(),
531                        target: name.clone(),
532                        available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
533                    }
534                    .fail()
535                }
536            }
537            BuildTarget::Example(ref name) => {
538                // Explicit example target - verify it exists
539                if examples.iter().any(|t| t.name == *name) {
540                    Ok(build_target)
541                } else {
542                    error::RunnableTargetNotFoundSnafu {
543                        kind: "example",
544                        package: krate.resolved.name.clone(),
545                        target: name.clone(),
546                        available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
547                    }
548                    .fail()
549                }
550            }
551        }
552    }
553
554    /// Build the crate from source as-is, as well as the SBOM for the as-built crate, without any
555    /// caching.
556    ///
557    /// Uses metadata previously gathered from the crate to resolve the package name containing the
558    /// crate, but beware that the act of building can and often does modify the metadata,
559    /// particularly if there is no Cargo.lock in the source package or if it's out of date and
560    /// needs to be updated.
561    ///
562    /// ## Cargo.lock handling
563    ///
564    /// We control whether cargo uses locked dependencies via two mechanisms:
565    ///
566    /// - File presence (`prepare_build_dir`): If options.locked is false, we delete Cargo.lock
567    ///   before building, forcing cargo to resolve dependencies fresh.
568    ///
569    /// - --locked flag (passed to `cargo build` in cargo.rs): If options.locked is true, cargo.rs
570    ///   passes --locked to `cargo build`, making it strictly honor the Cargo.lock and fail if
571    ///   inconsistent.
572    ///
573    /// This two-part approach mimics `cargo install` behavior:
574    /// - `cargo install --locked`: keeps Cargo.lock + enforces strict adherence (via
575    ///   `ws.set_ignore_lock(false)`)
576    /// - `cargo install`: ignores/regenerates Cargo.lock with latest compatible versions
577    ///
578    /// ## Returns
579    ///
580    /// Returns a tuple of (`binary_path`, `sbom`) where `sbom` is generated from metadata
581    /// read from the build directory AFTER the build completes. This ensures the SBOM
582    /// reflects the actual dependencies that were resolved and built, not what was
583    /// in the source directory's Cargo.lock.
584    fn build_uncached(
585        &self,
586        krate: &DownloadedCrate,
587        options: &BuildOptions,
588        metadata: &Metadata,
589    ) -> Result<(PathBuf, crate::sbom::CycloneDx)> {
590        let build_dir = self.prepare_build_dir(krate, options)?;
591
592        let package_name = Self::resolve_package_name(metadata, &krate.resolved.name)?;
593
594        let binary_path = self
595            .cargo_runner
596            .build(&build_dir, package_name.as_deref(), options)?;
597
598        // Re-read metadata from the build directory AFTER building. This is critical for accurate
599        // SBOM generation: if --unlocked was used, Cargo.lock was deleted from the build dir and
600        // cargo created a new one with freshly resolved dependencies. Even absent `--unlocked`, if
601        // the crate didn't ship with a Cargo.lock or it was outdated, the act of building will
602        // update the lock file and resolve potentially different dependencies when building the
603        // crate.  Since the SBOM must reflect those actual dependencies, not the stale ones from
604        // the source directory, we need to re-read the metadata here.
605        let metadata = self
606            .cargo_runner
607            .metadata(&build_dir, &CargoMetadataOptions::from(options))?;
608
609        // Generate SBOM from the post-build metadata
610        let sbom = crate::sbom::generate_sbom(&metadata, &krate.resolved, options)?;
611
612        Ok((binary_path, sbom))
613    }
614
615    /// Prepare a build directory from which the crate can be built.
616    ///
617    /// If the crate is in a local path, then that path is returned directly, meaning what we will
618    /// do is equivalent to running `cargo build --release` in that directory.
619    ///
620    /// For all other crates (e.g., from crates.io or git), a temporary directory is created in the
621    /// build dir, and the crate's source files are copied there.  This ensures that any build
622    /// artifacts (e.g., `target` directory) are created in a location that is not under the
623    /// user's source tree. The temporary directory is not automatically deleted, but is left
624    /// for inspection.
625    ///
626    /// TODO: Fix this so that build dirs are cleaned up after successful builds.
627    fn prepare_build_dir(&self, krate: &DownloadedCrate, options: &BuildOptions) -> Result<PathBuf> {
628        if let ResolvedSource::LocalDir { .. } = krate.resolved.source {
629            return Ok(krate.crate_path.clone());
630        }
631
632        std::fs::create_dir_all(&self.config.build_dir).with_context(|_| error::IoSnafu {
633            path: self.config.build_dir.clone(),
634        })?;
635
636        let temp_dir = tempfile::Builder::new()
637            .prefix(&format!("cgx-build-{}", &krate.resolved.name))
638            .tempdir_in(&self.config.build_dir)
639            .with_context(|_| error::TempDirInCreationSnafu {
640                parent: self.config.build_dir.clone(),
641            })?;
642
643        let temp_path = temp_dir.path().to_path_buf();
644        crate::helpers::copy_source_tree(&krate.crate_path, &temp_path)?;
645
646        // If locked is false (--unlocked was passed), delete Cargo.lock from copied source builds
647        // to force Cargo to resolve dependencies fresh.
648        if !options.locked {
649            let lock_path = temp_path.join("Cargo.lock");
650            if lock_path.exists() {
651                std::fs::remove_file(&lock_path).with_context(|_| error::IoSnafu { path: lock_path })?;
652            }
653        }
654
655        let _ = temp_dir.keep();
656        Ok(temp_path)
657    }
658
659    /// Given metadata for a workspace and the name of a crate, determine the appropriate
660    /// `--package` argument to pass to cargo, if any.
661    ////
662    /// If the workspace has zero or one members, then no `--package` argument is needed, so
663    /// `Ok(None)` is returned.  If the workspace has multiple members, then the crate name must
664    /// match one of them, and `Ok(Some(name))` is returned.  If it does not match any, then an
665    /// error is returned.
666    fn resolve_package_name(metadata: &Metadata, crate_name: &str) -> Result<Option<String>> {
667        let workspace_members: Vec<_> = metadata
668            .workspace_packages()
669            .iter()
670            .map(|p| p.name.as_str())
671            .collect();
672
673        match workspace_members.len() {
674            0 | 1 => Ok(None),
675            _ => {
676                if workspace_members.iter().any(|name| *name == crate_name) {
677                    Ok(Some(crate_name.to_string()))
678                } else {
679                    error::PackageNotFoundInWorkspaceSnafu {
680                        name: crate_name.to_string(),
681                        available: workspace_members
682                            .into_iter()
683                            .map(String::from)
684                            .collect::<Vec<_>>(),
685                    }
686                    .fail()
687                }
688            }
689        }
690    }
691}
692
693#[cfg(test)]
694mod tests {
695    use std::{fs, path::Path};
696
697    use assert_matches::assert_matches;
698    use semver::Version;
699
700    use super::*;
701    use crate::{
702        cargo::create_cargo_runner,
703        crate_resolver::{ResolvedCrate, ResolvedSource},
704        error::Error,
705        testdata::CrateTestCase,
706    };
707
708    fn test_builder() -> (RealCrateBuilder, tempfile::TempDir) {
709        crate::logging::init_test_logging();
710
711        let (temp_dir, config) = crate::config::create_test_env();
712
713        fs::create_dir_all(&config.cache_dir).unwrap();
714        fs::create_dir_all(&config.bin_dir).unwrap();
715        fs::create_dir_all(&config.build_dir).unwrap();
716
717        let cache = Cache::new(config.clone(), crate::messages::MessageReporter::null());
718        let cargo_runner =
719            Arc::new(create_cargo_runner(config.clone(), crate::messages::MessageReporter::null()).unwrap());
720
721        let builder = RealCrateBuilder {
722            config,
723            cache,
724            cargo_runner,
725        };
726
727        (builder, temp_dir)
728    }
729
730    /// Type of fake source to create for testing
731    #[derive(Debug, Clone)]
732    enum FakeSourceType {
733        Registry { version: String },
734        Git { url: String, rev: String },
735        LocalDir,
736    }
737
738    /// Create a fake [`DownloadedCrate`] from a [`CrateTestCase`] for testing different source
739    /// types
740    fn fake_downloaded_crate(
741        tc: &CrateTestCase,
742        source_type: FakeSourceType,
743        package_name: Option<&str>,
744    ) -> DownloadedCrate {
745        let (resolved_source, crate_path) = match &source_type {
746            FakeSourceType::Registry { .. } => {
747                // Registry sources only contain the specific crate, not the whole workspace
748                let path = if let Some(pkg) = package_name {
749                    tc.path().join(pkg)
750                } else {
751                    tc.path().to_path_buf()
752                };
753                (ResolvedSource::CratesIo, path)
754            }
755            FakeSourceType::Git { url, rev } => {
756                // Git sources can contain workspaces
757                (
758                    ResolvedSource::Git {
759                        repo: url.clone(),
760                        commit: rev.clone(),
761                    },
762                    tc.path().to_path_buf(),
763                )
764            }
765            FakeSourceType::LocalDir => {
766                // LocalDir sources use the path directly
767                let path = tc.path().to_path_buf();
768                (ResolvedSource::LocalDir { path: path.clone() }, path)
769            }
770        };
771
772        let name = package_name.unwrap_or(tc.name).to_string();
773        let version = match &source_type {
774            FakeSourceType::Registry { version } => Version::parse(version).unwrap(),
775            _ => Version::parse("0.1.0").unwrap(),
776        };
777
778        DownloadedCrate {
779            resolved: ResolvedCrate {
780                name,
781                version,
782                source: resolved_source,
783            },
784            crate_path,
785        }
786    }
787
788    /// Return the SBOM path for a built binary in the cache.
789    fn read_sbom_for_binary(binary_path: &Path) -> PathBuf {
790        // SBOM is stored at same level as binary with name "sbom.cyclonedx.json"
791        binary_path.parent().unwrap().join("sbom.cyclonedx.json")
792    }
793
794    /// Get the expected binary name for the current platform.
795    ///
796    /// On Windows, appends ".exe" extension. On Unix, returns the name unchanged.
797    fn expected_bin_name(base_name: &str) -> String {
798        format!("{}{}", base_name, std::env::consts::EXE_SUFFIX)
799    }
800
801    /// Assert that two builds resulted in a cache hit (same path, same mtime)
802    fn assert_cache_hit(path1: &Path, path2: &Path) {
803        assert_eq!(
804            path1,
805            path2,
806            "Cache hit expected: paths should be identical\n  path1: {}\n  path2: {}",
807            path1.display(),
808            path2.display()
809        );
810
811        let mtime1 = fs::metadata(path1).unwrap().modified().unwrap();
812        let mtime2 = fs::metadata(path2).unwrap().modified().unwrap();
813
814        assert_eq!(
815            mtime1,
816            mtime2,
817            "Cache hit expected: modification times should be identical\n  path1: {}\n  path2: {}",
818            path1.display(),
819            path2.display()
820        );
821    }
822
823    /// Assert that two builds resulted in a cache miss (different path OR different mtime)
824    fn assert_cache_miss(path1: &Path, path2: &Path) {
825        let paths_differ = path1 != path2;
826        let mtimes_differ = if path1.exists() && path2.exists() {
827            let mtime1 = fs::metadata(path1).unwrap().modified().unwrap();
828            let mtime2 = fs::metadata(path2).unwrap().modified().unwrap();
829            mtime1 != mtime2
830        } else {
831            true
832        };
833
834        assert!(
835            paths_differ || mtimes_differ,
836            "Cache miss expected: paths or mtimes should differ\n  path1: {}\n  path2: {}\n  paths_differ: \
837             {}\n  mtimes_differ: {}",
838            path1.display(),
839            path2.display(),
840            paths_differ,
841            mtimes_differ
842        );
843    }
844
845    /// Output from running the timestamp test binary.
846    #[derive(Debug)]
847    struct TimestampOutput {
848        build_timestamp: String,
849        features: Vec<String>,
850    }
851
852    /// Run the timestamp binary and parse its output.
853    fn run_timestamp_binary(path: &Path) -> TimestampOutput {
854        let output = std::process::Command::new(path)
855            .output()
856            .unwrap_or_else(|e| panic!("Failed to execute timestamp binary at {}: {}", path.display(), e));
857
858        assert!(
859            output.status.success(),
860            "Timestamp binary failed with status {}: {}",
861            output.status,
862            String::from_utf8_lossy(&output.stderr)
863        );
864
865        let stdout = String::from_utf8_lossy(&output.stdout);
866
867        let mut build_timestamp = None;
868        let mut features = Vec::new();
869
870        for line in stdout.lines() {
871            if let Some(ts) = line.strip_prefix("Built at: ") {
872                build_timestamp = Some(ts.to_string());
873            }
874            if let Some(feat_str) = line.strip_prefix("Features enabled: ") {
875                if feat_str != "none" {
876                    features = feat_str.split(", ").map(|s| s.to_string()).collect();
877                }
878            }
879        }
880
881        TimestampOutput {
882            build_timestamp: build_timestamp.expect("No 'Built at:' line in timestamp output"),
883            features,
884        }
885    }
886
887    /// Assert that two builds hit cache by comparing timestamps (should be identical).
888    fn assert_cache_hit_by_timestamp(output1: &TimestampOutput, output2: &TimestampOutput) {
889        assert_eq!(
890            output1.build_timestamp, output2.build_timestamp,
891            "Cache hit expected: build timestamps should match\n  ts1: {}\n  ts2: {}",
892            output1.build_timestamp, output2.build_timestamp
893        );
894    }
895
896    /// Assert that two builds missed cache by comparing timestamps (should differ).
897    fn assert_cache_miss_by_timestamp(output1: &TimestampOutput, output2: &TimestampOutput) {
898        assert_ne!(
899            output1.build_timestamp, output2.build_timestamp,
900            "Cache miss expected: build timestamps should differ\n  ts1: {}\n  ts2: {}",
901            output1.build_timestamp, output2.build_timestamp
902        );
903    }
904
905    mod smoke_tests {
906        use super::*;
907
908        #[test]
909        fn builds_all_testcases_with_bins() {
910            let (builder, _temp) = test_builder();
911            let cargo_runner =
912                create_cargo_runner(Config::default(), crate::messages::MessageReporter::null()).unwrap();
913
914            for tc in CrateTestCase::all() {
915                let metadata_opts = CargoMetadataOptions::default();
916                let metadata = cargo_runner.metadata(tc.path(), &metadata_opts).unwrap();
917
918                let workspace_pkgs = metadata.workspace_packages();
919                let buildable_packages: Vec<_> = workspace_pkgs
920                    .iter()
921                    .filter(|pkg| {
922                        pkg.targets.iter().any(|t| {
923                            t.kind
924                                .iter()
925                                .any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
926                        })
927                    })
928                    .collect();
929
930                if buildable_packages.is_empty() {
931                    continue;
932                }
933
934                for pkg in buildable_packages {
935                    let krate = fake_downloaded_crate(
936                        &tc,
937                        FakeSourceType::Registry {
938                            version: "1.0.0".to_string(),
939                        },
940                        Some(&pkg.name),
941                    );
942
943                    let options = BuildOptions {
944                        profile: Some("dev".to_string()),
945                        ..Default::default()
946                    };
947
948                    let result = builder.build(&krate, &options);
949
950                    if let Ok((binary, _target)) = result {
951                        assert!(binary.exists(), "Binary missing for {}/{}", tc.name, pkg.name);
952
953                        let binary_name = binary.file_name().unwrap().to_str().unwrap();
954
955                        // Determine expected binary name based on package metadata
956                        let bin_targets: Vec<_> = pkg
957                            .targets
958                            .iter()
959                            .filter(|t| {
960                                t.kind
961                                    .iter()
962                                    .any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
963                            })
964                            .collect();
965
966                        let expected_name = if bin_targets.len() == 1 {
967                            // Single binary - use its name
968                            bin_targets[0].name.as_str()
969                        } else if let Some(ref default_run) = pkg.default_run {
970                            // Multiple binaries with default - use default
971                            default_run.as_str()
972                        } else {
973                            // Multiple binaries without default - should have failed
974                            panic!(
975                                "Build succeeded for {}/{} but should have failed due to ambiguous binary \
976                                 target",
977                                tc.name, pkg.name
978                            );
979                        };
980
981                        assert_eq!(
982                            binary_name,
983                            expected_bin_name(expected_name),
984                            "Wrong binary name for {}/{}: expected '{}', got '{}'",
985                            tc.name,
986                            pkg.name,
987                            expected_name,
988                            binary_name
989                        );
990                    }
991                }
992            }
993        }
994
995        #[test]
996        fn simple_bin_no_deps_from_registry() {
997            let (builder, _temp) = test_builder();
998            let tc = CrateTestCase::simple_bin_no_deps();
999            let krate = fake_downloaded_crate(
1000                &tc,
1001                FakeSourceType::Registry {
1002                    version: "1.0.0".to_string(),
1003                },
1004                None,
1005            );
1006
1007            let options = BuildOptions {
1008                profile: Some("dev".to_string()),
1009                ..Default::default()
1010            };
1011
1012            let (binary, _target) = builder.build(&krate, &options).unwrap();
1013
1014            assert!(binary.exists());
1015            assert!(binary.is_file());
1016            assert!(binary.starts_with(&builder.config.bin_dir));
1017
1018            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1019            assert_eq!(binary_name, expected_bin_name("simple-bin-no-deps"));
1020        }
1021    }
1022
1023    mod binary_selection {
1024        use super::*;
1025
1026        #[test]
1027        fn default_bin_selected_automatically() {
1028            let (builder, _temp) = test_builder();
1029            let tc = CrateTestCase::single_crate_multiple_bins_with_default();
1030            let krate = fake_downloaded_crate(
1031                &tc,
1032                FakeSourceType::Registry {
1033                    version: "1.0.0".to_string(),
1034                },
1035                None,
1036            );
1037
1038            let options = BuildOptions {
1039                profile: Some("dev".to_string()),
1040                build_target: BuildTarget::DefaultBin,
1041                ..Default::default()
1042            };
1043
1044            let (binary, target) = builder.build(&krate, &options).unwrap();
1045            assert!(binary.exists());
1046            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1047            assert_eq!(
1048                binary_name,
1049                expected_bin_name("bin1"),
1050                "Should build bin1 or the crate's default binary, got: {}",
1051                binary_name
1052            );
1053
1054            // The `DefaultBin` request is resolved to the concrete target that was built.
1055            assert_eq!(target, BuildTarget::Bin("bin1".to_string()));
1056        }
1057
1058        #[test]
1059        fn explicit_bin_overrides_default() {
1060            let (builder, _temp) = test_builder();
1061            let tc = CrateTestCase::single_crate_multiple_bins_with_default();
1062            let krate = fake_downloaded_crate(
1063                &tc,
1064                FakeSourceType::Registry {
1065                    version: "1.0.0".to_string(),
1066                },
1067                None,
1068            );
1069
1070            let options = BuildOptions {
1071                profile: Some("dev".to_string()),
1072                build_target: BuildTarget::Bin("bin2".to_string()),
1073                ..Default::default()
1074            };
1075
1076            let (binary, _target) = builder.build(&krate, &options).unwrap();
1077            assert!(binary.exists());
1078            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1079            assert_eq!(binary_name, expected_bin_name("bin2"));
1080        }
1081
1082        #[test]
1083        fn multiple_bins_without_default_fails() {
1084            let (builder, _temp) = test_builder();
1085            let tc = CrateTestCase::single_crate_multiple_bins();
1086            let krate = fake_downloaded_crate(
1087                &tc,
1088                FakeSourceType::Registry {
1089                    version: "1.0.0".to_string(),
1090                },
1091                None,
1092            );
1093
1094            let options = BuildOptions {
1095                profile: Some("dev".to_string()),
1096                ..Default::default()
1097            };
1098
1099            let result = builder.build(&krate, &options);
1100
1101            assert_matches!(
1102                result,
1103                Err(Error::AmbiguousBinaryTarget { ref package, ref available })
1104                    if package == "single-crate-multiple-bins"
1105                        && available.len() == 2
1106                        && available.contains(&"bin1".to_string())
1107                        && available.contains(&"bin2".to_string())
1108            );
1109        }
1110    }
1111
1112    mod workspace_handling {
1113        use super::*;
1114
1115        #[test]
1116        fn workspace_with_correct_package_succeeds() {
1117            let (builder, _temp) = test_builder();
1118            let tc = CrateTestCase::workspace_multiple_bin_crates();
1119            let krate = fake_downloaded_crate(
1120                &tc,
1121                FakeSourceType::Git {
1122                    url: "https://github.com/example/test.git".to_string(),
1123                    rev: "abc123".to_string(),
1124                },
1125                Some("bin1"),
1126            );
1127
1128            let options = BuildOptions {
1129                profile: Some("dev".to_string()),
1130                ..Default::default()
1131            };
1132
1133            let (binary, _target) = builder.build(&krate, &options).unwrap();
1134            assert!(binary.exists());
1135
1136            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1137            assert_eq!(binary_name, expected_bin_name("bin1"));
1138        }
1139
1140        #[test]
1141        fn workspace_with_wrong_package_fails() {
1142            let (builder, _temp) = test_builder();
1143            let tc = CrateTestCase::workspace_multiple_bin_crates();
1144
1145            let krate = DownloadedCrate {
1146                resolved: ResolvedCrate {
1147                    name: "nonexistent-package".to_string(),
1148                    version: Version::parse("1.0.0").unwrap(),
1149                    source: ResolvedSource::CratesIo,
1150                },
1151                crate_path: tc.path().to_path_buf(),
1152            };
1153
1154            let options = BuildOptions {
1155                profile: Some("dev".to_string()),
1156                ..Default::default()
1157            };
1158
1159            let result = builder.build(&krate, &options);
1160
1161            assert_matches!(
1162                result,
1163                Err(Error::PackageNotFoundInWorkspace { ref name, ref available })
1164                    if name == "nonexistent-package" && !available.is_empty()
1165            );
1166        }
1167    }
1168
1169    mod cache_functional {
1170        use super::*;
1171
1172        #[test]
1173        fn identical_builds_hit_cache() {
1174            let (builder, _temp) = test_builder();
1175            let tc = CrateTestCase::timestamp();
1176
1177            let krate1 = fake_downloaded_crate(
1178                &tc,
1179                FakeSourceType::Registry {
1180                    version: "1.0.0".to_string(),
1181                },
1182                None,
1183            );
1184            let options = BuildOptions {
1185                profile: Some("dev".to_string()),
1186                ..Default::default()
1187            };
1188
1189            let (binary1, _target) = builder.build(&krate1, &options).unwrap();
1190            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1191            assert_eq!(binary1_name, expected_bin_name("timestamp"));
1192            let output1 = run_timestamp_binary(&binary1);
1193
1194            std::thread::sleep(std::time::Duration::from_millis(100));
1195
1196            let krate2 = fake_downloaded_crate(
1197                &tc,
1198                FakeSourceType::Registry {
1199                    version: "1.0.0".to_string(),
1200                },
1201                None,
1202            );
1203
1204            let (binary2, _target) = builder.build(&krate2, &options).unwrap();
1205            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1206            assert_eq!(binary2_name, expected_bin_name("timestamp"));
1207            let output2 = run_timestamp_binary(&binary2);
1208
1209            assert_cache_hit_by_timestamp(&output1, &output2);
1210            assert_cache_hit(&binary1, &binary2);
1211        }
1212
1213        #[test]
1214        fn different_profile_cache_miss() {
1215            let (builder, _temp) = test_builder();
1216            let tc = CrateTestCase::timestamp();
1217
1218            let krate1 = fake_downloaded_crate(
1219                &tc,
1220                FakeSourceType::Registry {
1221                    version: "1.0.0".to_string(),
1222                },
1223                None,
1224            );
1225            let options1 = BuildOptions {
1226                profile: Some("dev".to_string()),
1227                ..Default::default()
1228            };
1229            let (binary1, _target) = builder.build(&krate1, &options1).unwrap();
1230            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1231            assert_eq!(binary1_name, expected_bin_name("timestamp"));
1232            let output1 = run_timestamp_binary(&binary1);
1233
1234            let krate2 = fake_downloaded_crate(
1235                &tc,
1236                FakeSourceType::Registry {
1237                    version: "1.0.0".to_string(),
1238                },
1239                None,
1240            );
1241            let options2 = BuildOptions {
1242                profile: Some("release".to_string()),
1243                ..Default::default()
1244            };
1245            let (binary2, _target) = builder.build(&krate2, &options2).unwrap();
1246            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1247            assert_eq!(binary2_name, expected_bin_name("timestamp"));
1248            let output2 = run_timestamp_binary(&binary2);
1249
1250            assert_cache_miss_by_timestamp(&output1, &output2);
1251            assert_cache_miss(&binary1, &binary2);
1252        }
1253
1254        #[test]
1255        fn different_target_cache_miss() {
1256            let (builder, _temp) = test_builder();
1257            let tc = CrateTestCase::simple_bin_no_deps();
1258
1259            let krate1 = fake_downloaded_crate(
1260                &tc,
1261                FakeSourceType::Registry {
1262                    version: "1.0.0".to_string(),
1263                },
1264                None,
1265            );
1266            let options1 = BuildOptions {
1267                profile: Some("dev".to_string()),
1268                target: None,
1269                ..Default::default()
1270            };
1271            let (binary1, _target) = builder.build(&krate1, &options1).unwrap();
1272            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1273            assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
1274
1275            let krate2 = fake_downloaded_crate(
1276                &tc,
1277                FakeSourceType::Registry {
1278                    version: "1.0.0".to_string(),
1279                },
1280                None,
1281            );
1282            let options2 = BuildOptions {
1283                profile: Some("dev".to_string()),
1284                target: Some(TargetTriple::host().clone()),
1285                ..Default::default()
1286            };
1287            let (binary2, _target) = builder.build(&krate2, &options2).unwrap();
1288            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1289            assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
1290
1291            assert_cache_miss(&binary1, &binary2);
1292        }
1293    }
1294
1295    mod dependency_resolution {
1296        use super::*;
1297        use crate::sbom::tests::get_sbom_component_version;
1298
1299        #[test]
1300        fn locked_vs_unlocked_produces_different_cache_entries() {
1301            let (builder, _temp) = test_builder();
1302            let tc = CrateTestCase::stale_serde();
1303
1304            let krate1 = fake_downloaded_crate(
1305                &tc,
1306                FakeSourceType::Registry {
1307                    version: "1.0.0".to_string(),
1308                },
1309                None,
1310            );
1311            let options1 = BuildOptions {
1312                profile: Some("dev".to_string()),
1313                locked: true,
1314                ..Default::default()
1315            };
1316            let (binary1, _target) = builder.build(&krate1, &options1).unwrap();
1317            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1318            assert_eq!(binary1_name, expected_bin_name("stale-serde"));
1319            let sbom1 = read_sbom_for_binary(&binary1);
1320
1321            assert_eq!(
1322                get_sbom_component_version(&sbom1, "serde"),
1323                Some("1.0.5".to_string()),
1324                "With --locked, should use old serde from Cargo.lock"
1325            );
1326
1327            let krate2 = fake_downloaded_crate(
1328                &tc,
1329                FakeSourceType::Registry {
1330                    version: "1.0.0".to_string(),
1331                },
1332                None,
1333            );
1334            let options2 = BuildOptions {
1335                profile: Some("dev".to_string()),
1336                locked: false,
1337                ..Default::default()
1338            };
1339            let (binary2, _target) = builder.build(&krate2, &options2).unwrap();
1340            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1341            assert_eq!(binary2_name, expected_bin_name("stale-serde"));
1342            let sbom2 = read_sbom_for_binary(&binary2);
1343
1344            let version = get_sbom_component_version(&sbom2, "serde").unwrap();
1345            assert_ne!(
1346                version, "1.0.5",
1347                "Without --locked, should resolve to newer serde"
1348            );
1349            assert!(version.starts_with("1.0."), "Should still be serde 1.0.x");
1350
1351            crate::sbom::tests::assert_sboms_ne(&sbom1, &sbom2);
1352            assert_cache_miss(&binary1, &binary2);
1353        }
1354
1355        #[test]
1356        fn same_locked_flag_produces_cache_hit() {
1357            let (builder, _temp) = test_builder();
1358            let tc = CrateTestCase::stale_serde();
1359
1360            let krate1 = fake_downloaded_crate(
1361                &tc,
1362                FakeSourceType::Registry {
1363                    version: "1.0.0".to_string(),
1364                },
1365                None,
1366            );
1367            let options = BuildOptions {
1368                profile: Some("dev".to_string()),
1369                locked: true,
1370                ..Default::default()
1371            };
1372
1373            let (binary1, _target) = builder.build(&krate1, &options).unwrap();
1374            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1375            assert_eq!(binary1_name, expected_bin_name("stale-serde"));
1376
1377            let krate2 = fake_downloaded_crate(
1378                &tc,
1379                FakeSourceType::Registry {
1380                    version: "1.0.0".to_string(),
1381                },
1382                None,
1383            );
1384
1385            let (binary2, _target) = builder.build(&krate2, &options).unwrap();
1386            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1387            assert_eq!(binary2_name, expected_bin_name("stale-serde"));
1388
1389            assert_cache_hit(&binary1, &binary2);
1390        }
1391
1392        #[test]
1393        fn different_features_different_dependencies() {
1394            let (builder, _temp) = test_builder();
1395            let tc = CrateTestCase::timestamp();
1396
1397            let krate1 = fake_downloaded_crate(
1398                &tc,
1399                FakeSourceType::Registry {
1400                    version: "1.0.0".to_string(),
1401                },
1402                None,
1403            );
1404            let options1 = BuildOptions {
1405                profile: Some("dev".to_string()),
1406                ..Default::default()
1407            };
1408            let (binary1, _target) = builder.build(&krate1, &options1).unwrap();
1409            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1410            assert_eq!(binary1_name, expected_bin_name("timestamp"));
1411            let sbom1 = read_sbom_for_binary(&binary1);
1412            let output1 = run_timestamp_binary(&binary1);
1413
1414            let krate2 = fake_downloaded_crate(
1415                &tc,
1416                FakeSourceType::Registry {
1417                    version: "1.0.0".to_string(),
1418                },
1419                None,
1420            );
1421            let options2 = BuildOptions {
1422                profile: Some("dev".to_string()),
1423                features: vec!["frobnulator".to_string()],
1424                no_default_features: true,
1425                ..Default::default()
1426            };
1427            let (binary2, _target) = builder.build(&krate2, &options2).unwrap();
1428            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1429            assert_eq!(binary2_name, expected_bin_name("timestamp"));
1430            let sbom2 = read_sbom_for_binary(&binary2);
1431            let output2 = run_timestamp_binary(&binary2);
1432
1433            assert!(output1.features.contains(&"gonkolator".to_string()));
1434            assert!(output2.features.contains(&"frobnulator".to_string()));
1435
1436            crate::sbom::tests::assert_sboms_ne(&sbom1, &sbom2);
1437            assert_cache_miss_by_timestamp(&output1, &output2);
1438        }
1439
1440        #[test]
1441        fn all_features_includes_all_dependencies() {
1442            let (builder, _temp) = test_builder();
1443            let tc = CrateTestCase::timestamp();
1444
1445            let krate = fake_downloaded_crate(
1446                &tc,
1447                FakeSourceType::Registry {
1448                    version: "1.0.0".to_string(),
1449                },
1450                None,
1451            );
1452            let options = BuildOptions {
1453                profile: Some("dev".to_string()),
1454                all_features: true,
1455                ..Default::default()
1456            };
1457
1458            let (binary, _target) = builder.build(&krate, &options).unwrap();
1459            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1460            assert_eq!(binary_name, expected_bin_name("timestamp"));
1461            let output = run_timestamp_binary(&binary);
1462
1463            assert!(
1464                output.features.contains(&"gonkolator".to_string()),
1465                "Should have gonkolator"
1466            );
1467            assert!(
1468                output.features.contains(&"frobnulator".to_string()),
1469                "Should have frobnulator"
1470            );
1471        }
1472
1473        #[test]
1474        fn default_is_locked_true() {
1475            let (builder, _temp) = test_builder();
1476            let tc = CrateTestCase::stale_serde();
1477
1478            let krate = fake_downloaded_crate(
1479                &tc,
1480                FakeSourceType::Registry {
1481                    version: "1.0.0".to_string(),
1482                },
1483                None,
1484            );
1485            let options = BuildOptions::default();
1486
1487            let (binary, _target) = builder.build(&krate, &options).unwrap();
1488            let sbom = read_sbom_for_binary(&binary);
1489
1490            assert_eq!(
1491                get_sbom_component_version(&sbom, "serde"),
1492                Some("1.0.5".to_string()),
1493                "Default (locked=true) should honor Cargo.lock"
1494            );
1495        }
1496
1497        #[test]
1498        fn frozen_honors_cargo_lock_and_is_offline() {
1499            let (builder, _temp) = test_builder();
1500            let tc = CrateTestCase::stale_serde();
1501
1502            let krate = fake_downloaded_crate(
1503                &tc,
1504                FakeSourceType::Registry {
1505                    version: "1.0.0".to_string(),
1506                },
1507                None,
1508            );
1509            let options = BuildOptions {
1510                profile: Some("dev".to_string()),
1511                locked: true,
1512                offline: true,
1513                ..Default::default()
1514            };
1515
1516            let (binary, _target) = builder.build(&krate, &options).unwrap();
1517            let sbom = read_sbom_for_binary(&binary);
1518
1519            assert_eq!(
1520                get_sbom_component_version(&sbom, "serde"),
1521                Some("1.0.5".to_string()),
1522                "Frozen should honor Cargo.lock"
1523            );
1524
1525            assert!(options.offline, "Frozen should set offline mode");
1526        }
1527    }
1528
1529    mod source_types {
1530        use super::*;
1531
1532        #[test]
1533        fn local_dir_never_cached() {
1534            let (builder, _temp) = test_builder();
1535            let tc = CrateTestCase::simple_bin_no_deps();
1536
1537            let krate = fake_downloaded_crate(&tc, FakeSourceType::LocalDir, None);
1538
1539            let options = BuildOptions {
1540                profile: Some("dev".to_string()),
1541                ..Default::default()
1542            };
1543
1544            let (binary, _target) = builder.build(&krate, &options).unwrap();
1545
1546            assert!(!binary.starts_with(&builder.config.bin_dir));
1547            assert!(binary.starts_with(tc.path()));
1548
1549            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1550            assert_eq!(binary_name, expected_bin_name("simple-bin-no-deps"));
1551
1552            let sbom_path = read_sbom_for_binary(&binary);
1553            assert!(!sbom_path.exists());
1554        }
1555
1556        #[test]
1557        fn registry_source_cached_with_sbom() {
1558            let (builder, _temp) = test_builder();
1559            let tc = CrateTestCase::simple_bin_no_deps();
1560
1561            let krate1 = fake_downloaded_crate(
1562                &tc,
1563                FakeSourceType::Registry {
1564                    version: "1.0.0".to_string(),
1565                },
1566                None,
1567            );
1568            let options = BuildOptions {
1569                profile: Some("dev".to_string()),
1570                ..Default::default()
1571            };
1572
1573            let (binary1, _target) = builder.build(&krate1, &options).unwrap();
1574
1575            assert!(binary1.starts_with(&builder.config.bin_dir));
1576
1577            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1578            assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
1579
1580            let sbom_path = read_sbom_for_binary(&binary1);
1581            assert!(sbom_path.exists());
1582
1583            let krate2 = fake_downloaded_crate(
1584                &tc,
1585                FakeSourceType::Registry {
1586                    version: "1.0.0".to_string(),
1587                },
1588                None,
1589            );
1590            let (binary2, _target) = builder.build(&krate2, &options).unwrap();
1591            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1592            assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
1593
1594            assert_cache_hit(&binary1, &binary2);
1595        }
1596
1597        #[test]
1598        fn git_source_cached_with_sbom() {
1599            let (builder, _temp) = test_builder();
1600            let tc = CrateTestCase::simple_bin_no_deps();
1601
1602            let krate1 = fake_downloaded_crate(
1603                &tc,
1604                FakeSourceType::Git {
1605                    url: "https://github.com/example/test.git".to_string(),
1606                    rev: "abc123".to_string(),
1607                },
1608                None,
1609            );
1610            let options = BuildOptions {
1611                profile: Some("dev".to_string()),
1612                ..Default::default()
1613            };
1614
1615            let (binary1, _target) = builder.build(&krate1, &options).unwrap();
1616
1617            assert!(binary1.starts_with(&builder.config.bin_dir));
1618
1619            let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
1620            assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
1621
1622            let sbom_path = read_sbom_for_binary(&binary1);
1623            assert!(sbom_path.exists());
1624
1625            let krate2 = fake_downloaded_crate(
1626                &tc,
1627                FakeSourceType::Git {
1628                    url: "https://github.com/example/test.git".to_string(),
1629                    rev: "abc123".to_string(),
1630                },
1631                None,
1632            );
1633            let (binary2, _target) = builder.build(&krate2, &options).unwrap();
1634            let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
1635            assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
1636
1637            assert_cache_hit(&binary1, &binary2);
1638        }
1639    }
1640
1641    mod proc_macro_detection {
1642        use super::*;
1643
1644        #[test]
1645        fn proc_macro_marked_as_build_dep() {
1646            let (builder, _temp) = test_builder();
1647            let tc = CrateTestCase::proc_macro_dep();
1648
1649            let krate = fake_downloaded_crate(
1650                &tc,
1651                FakeSourceType::Registry {
1652                    version: "1.0.0".to_string(),
1653                },
1654                None,
1655            );
1656            let options = BuildOptions {
1657                profile: Some("dev".to_string()),
1658                ..Default::default()
1659            };
1660
1661            let (binary, _target) = builder.build(&krate, &options).unwrap();
1662            let binary_name = binary.file_name().unwrap().to_str().unwrap();
1663            assert_eq!(binary_name, expected_bin_name("proc-macro-dep"));
1664
1665            let sbom_path = read_sbom_for_binary(&binary);
1666
1667            let json_str = fs::read_to_string(&sbom_path).unwrap();
1668            let bom: serde_cyclonedx::cyclonedx::v_1_4::CycloneDx = serde_json::from_str(&json_str).unwrap();
1669
1670            let components = bom.components.unwrap();
1671            let serde_derive = components
1672                .iter()
1673                .find(|c| c.name.as_str() == "serde_derive")
1674                .expect("serde_derive should be in components");
1675
1676            if let Some(ref props) = serde_derive.properties {
1677                let has_build_kind = props.iter().any(|p| {
1678                    p.name.as_deref() == Some("cdx:rustc:dependency_kind")
1679                        && p.value.as_deref() == Some("build")
1680                });
1681                assert!(has_build_kind, "proc-macro should be marked as build dependency");
1682            } else {
1683                panic!("proc-macro should have dependency_kind property");
1684            }
1685        }
1686    }
1687
1688    mod build_options {
1689        use super::*;
1690        use crate::cli::Cli;
1691
1692        mod features_parsing {
1693            use super::*;
1694
1695            /// Test that an empty features string produces an empty vec.
1696            #[test]
1697            fn empty_features_string() {
1698                let config = Config::default();
1699                let cli = Cli::parse_from_test_args(["--features", "", "tool"]);
1700                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1701
1702                assert!(options.features.is_empty());
1703            }
1704
1705            /// Test parsing a single feature.
1706            #[test]
1707            fn single_feature() {
1708                let config = Config::default();
1709                let cli = Cli::parse_from_test_args(["--features", "feat1", "tool"]);
1710                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1711
1712                assert_eq!(options.features, vec!["feat1"]);
1713            }
1714
1715            /// Test parsing comma-separated features.
1716            #[test]
1717            fn comma_separated_features() {
1718                let config = Config::default();
1719                let cli = Cli::parse_from_test_args(["--features", "feat1,feat2", "tool"]);
1720                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1721
1722                assert_eq!(options.features, vec!["feat1", "feat2"]);
1723            }
1724
1725            /// Test parsing space-separated features.
1726            #[test]
1727            fn space_separated_features() {
1728                let config = Config::default();
1729                let cli = Cli::parse_from_test_args(["--features", "feat1 feat2", "tool"]);
1730                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1731
1732                assert_eq!(options.features, vec!["feat1", "feat2"]);
1733            }
1734
1735            /// Test parsing features with mixed separators (commas and spaces).
1736            #[test]
1737            fn mixed_separator_features() {
1738                let config = Config::default();
1739                let cli = Cli::parse_from_test_args(["--features", "feat1, feat2 feat3", "tool"]);
1740                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1741
1742                assert_eq!(options.features, vec!["feat1", "feat2", "feat3"]);
1743            }
1744
1745            /// Test that leading and trailing whitespace is handled correctly.
1746            #[test]
1747            fn whitespace_handling() {
1748                let config = Config::default();
1749                let cli = Cli::parse_from_test_args(["--features", " feat1 , feat2 ", "tool"]);
1750                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1751
1752                assert_eq!(options.features, vec!["feat1", "feat2"]);
1753            }
1754
1755            /// Test that when no features flag is provided, features vec is empty.
1756            #[test]
1757            fn no_features_flag() {
1758                let config = Config::default();
1759                let cli = Cli::parse_from_test_args(["tool"]);
1760                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1761
1762                assert!(options.features.is_empty());
1763            }
1764        }
1765
1766        mod profile_selection {
1767            use super::*;
1768
1769            /// Test that `--debug` flag maps to "dev" profile.
1770            #[test]
1771            fn debug_flag_maps_to_dev() {
1772                let config = Config::default();
1773                let cli = Cli::parse_from_test_args(["--debug", "tool"]);
1774                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1775
1776                assert_eq!(options.profile, Some("dev".to_string()));
1777            }
1778
1779            /// Test that `--profile` flag sets the profile explicitly.
1780            #[test]
1781            fn explicit_profile() {
1782                let config = Config::default();
1783                let cli = Cli::parse_from_test_args(["--profile", "custom", "tool"]);
1784                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1785
1786                assert_eq!(options.profile, Some("custom".to_string()));
1787            }
1788
1789            /// Test that when neither flag is provided, profile is None.
1790            #[test]
1791            fn no_profile_specified() {
1792                let config = Config::default();
1793                let cli = Cli::parse_from_test_args(["tool"]);
1794                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1795
1796                assert_eq!(options.profile, None);
1797            }
1798        }
1799
1800        mod build_target_selection {
1801            use super::*;
1802
1803            /// Test that no flags produces [`BuildTarget::DefaultBin`].
1804            #[test]
1805            fn default_bin_when_no_flags() {
1806                let config = Config::default();
1807                let cli = Cli::parse_from_test_args(["tool"]);
1808                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1809
1810                assert_eq!(options.build_target, BuildTarget::DefaultBin);
1811            }
1812
1813            /// Test that `--bin` flag produces [`BuildTarget::Bin`].
1814            #[test]
1815            fn explicit_bin() {
1816                let config = Config::default();
1817                let cli = Cli::parse_from_test_args(["--bin", "foo", "tool"]);
1818                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1819
1820                assert_eq!(options.build_target, BuildTarget::Bin("foo".to_string()));
1821            }
1822
1823            /// Test that `--example` flag produces [`BuildTarget::Example`].
1824            #[test]
1825            fn explicit_example() {
1826                let config = Config::default();
1827                let cli = Cli::parse_from_test_args(["--example", "bar", "tool"]);
1828                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1829
1830                assert_eq!(options.build_target, BuildTarget::Example("bar".to_string()));
1831            }
1832        }
1833
1834        mod locked_offline_from_config {
1835            use super::*;
1836
1837            /// `BuildOptions` reads locked/offline from Config.
1838            ///
1839            /// CLI override tests (--locked, --unlocked, --frozen, --offline) belong in config.rs
1840            /// since that's where the CLI-to-Config override logic lives.
1841            #[test]
1842            fn reads_default_locked_true() {
1843                let config = Config::default();
1844                let cli = Cli::parse_from_test_args(["tool"]);
1845                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1846
1847                assert!(options.locked, "Should read locked=true from default Config");
1848                assert!(!options.offline, "Should read offline=false from default Config");
1849            }
1850
1851            #[test]
1852            fn reads_config_locked_false() {
1853                let config = Config {
1854                    locked: false,
1855                    ..Default::default()
1856                };
1857                let cli = Cli::parse_from_test_args(["tool"]);
1858                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1859
1860                assert!(!options.locked, "Should read locked=false from Config");
1861            }
1862
1863            #[test]
1864            fn reads_config_offline_true() {
1865                let config = Config {
1866                    offline: true,
1867                    ..Default::default()
1868                };
1869                let cli = Cli::parse_from_test_args(["tool"]);
1870                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1871
1872                assert!(options.offline, "Should read offline=true from Config");
1873            }
1874
1875            #[test]
1876            fn reads_config_both_values() {
1877                let config = Config {
1878                    locked: false,
1879                    offline: true,
1880                    ..Default::default()
1881                };
1882                let cli = Cli::parse_from_test_args(["tool"]);
1883                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1884
1885                assert!(!options.locked, "Should read locked=false from Config");
1886                assert!(options.offline, "Should read offline=true from Config");
1887            }
1888        }
1889
1890        mod toolchain_from_config {
1891            use super::*;
1892
1893            /// `BuildOptions` reads toolchain from Config.
1894            ///
1895            /// CLI override tests (+toolchain syntax) belong in config.rs since that's where
1896            /// the CLI-to-Config override logic lives.
1897
1898            #[test]
1899            fn reads_default_none() {
1900                let config = Config::default();
1901                let cli = Cli::parse_from_test_args(["tool"]);
1902                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1903
1904                assert_eq!(
1905                    options.toolchain, None,
1906                    "Should read toolchain=None from default Config"
1907                );
1908            }
1909
1910            #[test]
1911            fn reads_config_toolchain() {
1912                let config = Config {
1913                    toolchain: Some("stable".to_string()),
1914                    ..Default::default()
1915                };
1916                let cli = Cli::parse_from_test_args(["tool"]);
1917                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1918
1919                assert_eq!(
1920                    options.toolchain,
1921                    Some("stable".to_string()),
1922                    "Should read toolchain from Config"
1923                );
1924            }
1925
1926            #[test]
1927            fn reads_config_nightly() {
1928                let config = Config {
1929                    toolchain: Some("nightly".to_string()),
1930                    ..Default::default()
1931                };
1932                let cli = Cli::parse_from_test_args(["tool"]);
1933                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1934
1935                assert_eq!(
1936                    options.toolchain,
1937                    Some("nightly".to_string()),
1938                    "Should read toolchain from Config"
1939                );
1940            }
1941        }
1942
1943        mod direct_passthrough {
1944            use super::*;
1945
1946            /// Test that `--all-features` flag is passed through.
1947            #[test]
1948            fn all_features() {
1949                let config = Config::default();
1950                let cli = Cli::parse_from_test_args(["--all-features", "tool"]);
1951                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1952
1953                assert!(options.all_features);
1954            }
1955
1956            /// Test that `--no-default-features` flag is passed through.
1957            #[test]
1958            fn no_default_features() {
1959                let config = Config::default();
1960                let cli = Cli::parse_from_test_args(["--no-default-features", "tool"]);
1961                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1962
1963                assert!(options.no_default_features);
1964            }
1965
1966            /// Test that `--target` flag is passed through.
1967            #[test]
1968            fn target() {
1969                let config = Config::default();
1970                let cli = Cli::parse_from_test_args(["--target", "x86_64-unknown-linux-gnu", "tool"]);
1971                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1972
1973                assert_eq!(
1974                    options.target.as_ref().map(TargetTriple::as_str),
1975                    Some("x86_64-unknown-linux-gnu")
1976                );
1977            }
1978
1979            /// Any target rustc accepts must be forwarded to cargo verbatim, including triples
1980            /// that `target-lexicon` cannot parse (`arm64ec-pc-windows-msvc` is a real rustc
1981            /// target); cgx itself must not reject them.
1982            #[test]
1983            fn target_unparsable_by_target_lexicon() {
1984                let config = Config::default();
1985                let cli = Cli::parse_from_test_args(["--target", "arm64ec-pc-windows-msvc", "tool"]);
1986                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
1987
1988                assert_eq!(
1989                    options.target.as_ref().map(TargetTriple::as_str),
1990                    Some("arm64ec-pc-windows-msvc")
1991                );
1992            }
1993
1994            /// Test that `--jobs` flag is passed through.
1995            #[test]
1996            fn jobs() {
1997                let config = Config::default();
1998                let cli = Cli::parse_from_test_args(["--jobs", "4", "tool"]);
1999                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
2000
2001                assert_eq!(options.jobs, Some(4));
2002            }
2003
2004            /// Test that `--ignore-rust-version` flag is passed through.
2005            #[test]
2006            fn ignore_rust_version() {
2007                let config = Config::default();
2008                let cli = Cli::parse_from_test_args(["--ignore-rust-version", "tool"]);
2009                let options = BuildOptions::load(&config, &cli.crate_args().to_build_overrides()).unwrap();
2010
2011                assert!(options.ignore_rust_version);
2012            }
2013        }
2014    }
2015}