Skip to main content

cargo_bazel/cli/
vendor.rs

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