cargo_bazel/cli/
vendor.rs

1//! The cli entrypoint for the `vendor` subcommand
2
3use std::collections::{BTreeSet, HashMap};
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::{self, ExitStatus};
8use std::sync::Arc;
9
10use anyhow::{anyhow, bail, Context as AnyhowContext};
11use camino::Utf8PathBuf;
12use clap::Parser;
13
14use crate::config::{Config, VendorMode};
15use crate::context::Context;
16use crate::metadata::CargoUpdateRequest;
17use crate::metadata::TreeResolver;
18use crate::metadata::{Annotations, Cargo, Generator, MetadataGenerator, VendorGenerator};
19use crate::rendering::{render_module_label, write_outputs, Renderer};
20use crate::splicing::{generate_lockfile, Splicer, SplicingManifest, WorkspaceMetadata};
21use crate::utils::normalize_cargo_file_paths;
22
23/// Command line options for the `vendor` subcommand
24#[derive(Parser, Debug)]
25#[clap(about = "Command line options for the `vendor` subcommand", version)]
26pub struct VendorOptions {
27    /// The path to a Cargo binary to use for gathering metadata
28    #[clap(long, env = "CARGO")]
29    pub cargo: PathBuf,
30
31    /// The path to a rustc binary for use with Cargo
32    #[clap(long, env = "RUSTC")]
33    pub rustc: PathBuf,
34
35    /// The path to a buildifier binary for formatting generated BUILD files
36    #[clap(long)]
37    pub buildifier: Option<PathBuf>,
38
39    /// The config file with information about the Bazel and Cargo workspace
40    #[clap(long)]
41    pub config: PathBuf,
42
43    /// A generated manifest of splicing inputs
44    #[clap(long)]
45    pub splicing_manifest: PathBuf,
46
47    /// The path to a [Cargo.lock](https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html) file.
48    #[clap(long)]
49    pub cargo_lockfile: Option<PathBuf>,
50
51    /// A [Cargo config](https://doc.rust-lang.org/cargo/reference/config.html#configuration)
52    /// file to use when gathering metadata
53    #[clap(long)]
54    pub cargo_config: Option<PathBuf>,
55
56    /// The desired update/repin behavior. The arguments passed here are forward to
57    /// [cargo update](https://doc.rust-lang.org/cargo/commands/cargo-update.html). See
58    /// [crate::metadata::CargoUpdateRequest] for details on the values to pass here.
59    #[clap(long, env = "CARGO_BAZEL_REPIN", num_args=0..=1, default_missing_value = "true")]
60    pub repin: Option<CargoUpdateRequest>,
61
62    /// The path to a Cargo metadata `json` file.
63    #[clap(long)]
64    pub metadata: Option<PathBuf>,
65
66    /// The path to a bazel binary
67    #[clap(long, env = "BAZEL_REAL", default_value = "bazel")]
68    pub bazel: PathBuf,
69
70    /// The directory in which to build the workspace. A `Cargo.toml` file
71    /// should always be produced within this directory.
72    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
73    pub workspace_dir: PathBuf,
74
75    /// If true, outputs will be printed instead of written to disk.
76    #[clap(long)]
77    pub dry_run: bool,
78
79    /// The path to the Bazel root workspace (i.e. the directory containing the WORKSPACE.bazel file or similar).
80    /// BE CAREFUL with this value. We never want to include it in a lockfile hash (to keep lockfiles portable),
81    /// which means you also should not use it anywhere that _should_ be guarded by a lockfile hash.
82    /// You basically never want to use this value.
83    #[clap(long)]
84    pub nonhermetic_root_bazel_workspace_dir: Utf8PathBuf,
85}
86
87/// Run buildifier on a given file.
88fn buildifier_format(bin: &Path, file: &Path) -> anyhow::Result<ExitStatus> {
89    let status = process::Command::new(bin)
90        .args(["-lint=fix", "-mode=fix", "-warnings=all"])
91        .arg(file)
92        .status()
93        .context("Failed to apply buildifier fixes")?;
94
95    if !status.success() {
96        bail!(status)
97    }
98
99    Ok(status)
100}
101
102/// Run `bazel mod tidy` in a workspace.
103fn bzlmod_tidy(bin: &Path, workspace_dir: &Path) -> anyhow::Result<ExitStatus> {
104    let status = process::Command::new(bin)
105        .current_dir(workspace_dir)
106        .arg("mod")
107        .arg("tidy")
108        .status()
109        .context("Failed to spawn Bazel process")?;
110
111    if !status.success() {
112        bail!(status)
113    }
114
115    Ok(status)
116}
117
118/// Info about a Bazel workspace
119struct BazelInfo {
120    /// The version of Bazel being used
121    release: semver::Version,
122
123    /// The location of the output_user_root.
124    output_base: PathBuf,
125}
126
127impl BazelInfo {
128    /// Construct a new struct based on the current binary and workspace paths provided.
129    fn try_new(bazel: &Path, workspace_dir: &Path) -> anyhow::Result<Self> {
130        let output = process::Command::new(bazel)
131            .current_dir(workspace_dir)
132            .arg("info")
133            .arg("release")
134            .arg("output_base")
135            .output()
136            .context("Failed to query the Bazel workspace's `output_base`")?;
137
138        if !output.status.success() {
139            bail!(output.status)
140        }
141
142        let output = String::from_utf8_lossy(output.stdout.as_slice());
143        let mut bazel_info: HashMap<String, String> = output
144            .trim()
145            .split('\n')
146            .map(|line| {
147                let (k, v) = line.split_at(
148                    line.find(':')
149                        .ok_or_else(|| anyhow!("missing `:` in bazel info output: `{}`", line))?,
150                );
151                Ok((k.to_string(), (v[1..]).trim().to_string()))
152            })
153            .collect::<anyhow::Result<HashMap<_, _>>>()?;
154
155        // Allow a predefined environment variable to take precedent. This
156        // solves for the specific needs of Bazel CI on Github.
157        if let Ok(path) = env::var("OUTPUT_BASE") {
158            bazel_info.insert("output_base".to_owned(), format!("output_base: {}", path));
159        };
160
161        BazelInfo::try_from(bazel_info)
162    }
163}
164
165impl TryFrom<HashMap<String, String>> for BazelInfo {
166    type Error = anyhow::Error;
167
168    fn try_from(value: HashMap<String, String>) -> Result<Self, Self::Error> {
169        Ok(BazelInfo {
170            release: value
171                .get("release")
172                .map(|s| {
173                    let mut r = s
174                        .split_whitespace()
175                        .last()
176                        .ok_or_else(|| anyhow!("Unexpected release value: {}", s))?
177                        .to_owned();
178
179                    // Force release candidates to conform to semver.
180                    if r.contains("rc") {
181                        let (v, c) = r.split_once("rc").unwrap();
182                        r = format!("{}-rc{}", v, c);
183                    }
184
185                    semver::Version::parse(&r).context("Failed to parse release version")
186                })
187                .ok_or(anyhow!("Failed to query Bazel release"))??,
188            output_base: value
189                .get("output_base")
190                .map(Into::into)
191                .ok_or(anyhow!("Failed to query Bazel output_base"))?,
192        })
193    }
194}
195
196pub fn vendor(opt: VendorOptions) -> anyhow::Result<()> {
197    let bazel_info = BazelInfo::try_new(&opt.bazel, &opt.workspace_dir)?;
198
199    // Load the all config files required for splicing a workspace
200    let splicing_manifest = SplicingManifest::try_from_path(&opt.splicing_manifest)?
201        .resolve(&opt.workspace_dir, &bazel_info.output_base);
202
203    let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?;
204    let temp_dir_path = Utf8PathBuf::from_path_buf(temp_dir.as_ref().to_path_buf())
205        .unwrap_or_else(|path| panic!("Temporary directory wasn't valid UTF-8: {:?}", path));
206
207    // Generate a splicer for creating a Cargo workspace manifest
208    let splicer =
209        Splicer::new(temp_dir_path, splicing_manifest).context("Failed to create splicer")?;
210
211    let cargo = Cargo::new(opt.cargo, opt.rustc.clone());
212
213    // Splice together the manifest
214    let manifest_path = splicer
215        .splice_workspace()
216        .context("Failed to splice workspace")?;
217
218    // Gather a cargo lockfile
219    let cargo_lockfile = generate_lockfile(
220        &manifest_path,
221        &opt.cargo_lockfile,
222        cargo.clone(),
223        &opt.repin,
224    )?;
225
226    // Load the config from disk
227    let config = Config::try_from_path(&opt.config)?;
228
229    let resolver_data = TreeResolver::new(cargo.clone()).generate(
230        manifest_path.as_path_buf(),
231        &config.supported_platform_triples,
232    )?;
233
234    // Write the registry url info to the manifest now that a lockfile has been generated
235    WorkspaceMetadata::write_registry_urls_and_feature_map(
236        &cargo,
237        &cargo_lockfile,
238        resolver_data,
239        manifest_path.as_path_buf(),
240        manifest_path.as_path_buf(),
241    )?;
242
243    // Write metadata to the workspace for future reuse
244    let (cargo_metadata, cargo_lockfile) = Generator::new()
245        .with_cargo(cargo.clone())
246        .with_rustc(opt.rustc.clone())
247        .generate(manifest_path.as_path_buf())?;
248
249    // Annotate metadata
250    let annotations = Annotations::new(
251        cargo_metadata,
252        &opt.cargo_lockfile,
253        cargo_lockfile.clone(),
254        config.clone(),
255        &opt.nonhermetic_root_bazel_workspace_dir,
256    )?;
257
258    // Generate renderable contexts for earch package
259    let context = Context::new(annotations, config.rendering.are_sources_present())?;
260
261    // Render build files
262    let outputs = Renderer::new(
263        Arc::new(config.rendering.clone()),
264        Arc::new(config.supported_platform_triples),
265    )
266    .render(&context, None)?;
267
268    // First ensure vendoring and rendering happen in a clean directory
269    let vendor_dir_label = render_module_label(&config.rendering.crates_module_template, "BUILD")?;
270    let vendor_dir = opt.workspace_dir.join(vendor_dir_label.package().unwrap());
271    if vendor_dir.exists() {
272        fs::remove_dir_all(&vendor_dir)
273            .with_context(|| format!("Failed to delete {}", vendor_dir.display()))?;
274    }
275
276    // Store the updated Cargo.lock
277    if let Some(path) = &opt.cargo_lockfile {
278        fs::write(path, cargo_lockfile.to_string())
279            .context("Failed to write Cargo.lock file back to the workspace.")?;
280    }
281
282    if matches!(config.rendering.vendor_mode, Some(VendorMode::Local)) {
283        VendorGenerator::new(cargo, opt.rustc.clone())
284            .generate(manifest_path.as_path_buf(), &vendor_dir)
285            .context("Failed to vendor dependencies")?;
286    }
287
288    // make cargo versioned crates compatible with bazel labels
289    let normalized_outputs = normalize_cargo_file_paths(outputs, &opt.workspace_dir);
290
291    // buildifier files to check
292    let file_names: BTreeSet<PathBuf> = normalized_outputs.keys().cloned().collect();
293
294    // Write outputs
295    write_outputs(normalized_outputs, opt.dry_run).context("Failed writing output files")?;
296
297    // Optionally apply buildifier fixes
298    if let Some(buildifier_bin) = opt.buildifier {
299        for file in file_names {
300            let file_path = opt.workspace_dir.join(file);
301            buildifier_format(&buildifier_bin, &file_path)
302                .with_context(|| format!("Failed to run buildifier on {}", file_path.display()))?;
303        }
304    }
305
306    // Optionally perform bazel mod tidy to update the MODULE.bazel file
307    if bazel_info.release >= semver::Version::new(7, 0, 0) {
308        let module_bazel = opt.workspace_dir.join("MODULE.bazel");
309        if module_bazel.exists() {
310            bzlmod_tidy(&opt.bazel, &opt.workspace_dir)?;
311        }
312    }
313
314    Ok(())
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn test_bazel_info() {
323        let raw_info = HashMap::from([
324            ("release".to_owned(), "8.0.0".to_owned()),
325            ("output_base".to_owned(), "/tmp/output_base".to_owned()),
326        ]);
327
328        let info = BazelInfo::try_from(raw_info).unwrap();
329
330        assert_eq!(semver::Version::new(8, 0, 0), info.release);
331        assert_eq!(PathBuf::from("/tmp/output_base"), info.output_base);
332    }
333
334    #[test]
335    fn test_bazel_info_release_candidate() {
336        let raw_info = HashMap::from([
337            ("release".to_owned(), "8.0.0rc1".to_owned()),
338            ("output_base".to_owned(), "/tmp/output_base".to_owned()),
339        ]);
340
341        let info = BazelInfo::try_from(raw_info).unwrap();
342
343        assert_eq!(semver::Version::parse("8.0.0-rc1").unwrap(), info.release);
344        assert_eq!(PathBuf::from("/tmp/output_base"), info.output_base);
345    }
346
347    #[test]
348    fn test_bazel_info_pre_release() {
349        let raw_info = HashMap::from([
350            ("release".to_owned(), "9.0.0-pre.20241208.2".to_owned()),
351            ("output_base".to_owned(), "/tmp/output_base".to_owned()),
352        ]);
353
354        let info = BazelInfo::try_from(raw_info).unwrap();
355
356        assert_eq!(
357            semver::Version::parse("9.0.0-pre.20241208.2").unwrap(),
358            info.release
359        );
360        assert_eq!(PathBuf::from("/tmp/output_base"), info.output_base);
361    }
362}