1use 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#[derive(Parser, Debug)]
27#[clap(about = "Command line options for the `vendor` subcommand", version)]
28pub struct VendorOptions {
29 #[clap(long, env = "CARGO")]
31 pub cargo: PathBuf,
32
33 #[clap(long, env = "RUSTC")]
35 pub rustc: PathBuf,
36
37 #[clap(long)]
39 pub buildifier: Option<PathBuf>,
40
41 #[clap(long)]
43 pub config: PathBuf,
44
45 #[clap(long)]
47 pub splicing_manifest: PathBuf,
48
49 #[clap(long)]
51 pub lockfile: Option<PathBuf>,
52
53 #[clap(long)]
55 pub cargo_lockfile: Option<PathBuf>,
56
57 #[clap(long)]
60 pub cargo_config: Option<PathBuf>,
61
62 #[clap(long, env = "CARGO_BAZEL_REPIN", num_args=0..=1, default_missing_value = "true")]
66 pub repin: Option<CargoUpdateRequest>,
67
68 #[clap(long)]
70 pub metadata: Option<PathBuf>,
71
72 #[clap(long, env = "BAZEL_REAL", default_value = "bazel")]
74 pub bazel: PathBuf,
75
76 #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
79 pub workspace_dir: PathBuf,
80
81 #[clap(long)]
83 pub dry_run: bool,
84
85 #[clap(long)]
90 pub nonhermetic_root_bazel_workspace_dir: Utf8PathBuf,
91}
92
93fn 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
130fn 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
146struct BazelInfo {
148 release: semver::Version,
150
151 output_base: PathBuf,
153}
154
155impl BazelInfo {
156 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 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 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 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 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 let manifest_path = splicer
243 .splice_workspace(&opt.nonhermetic_root_bazel_workspace_dir)
244 .context("Failed to splice workspace")?;
245
246 let cargo_lockfile = generate_lockfile(
248 &manifest_path,
249 &opt.cargo_lockfile,
250 cargo.clone(),
251 &opt.repin,
252 )?;
253
254 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 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 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 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 let context = Context::new(annotations, config.rendering.are_sources_present())?;
290
291 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 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 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 let normalized_outputs = normalize_cargo_file_paths(outputs, &opt.workspace_dir);
320
321 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(normalized_outputs, opt.dry_run).context("Failed writing output files")?;
339
340 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 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}