1use 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#[derive(Parser, Debug)]
25#[clap(about = "Command line options for the `vendor` subcommand", version)]
26pub struct VendorOptions {
27 #[clap(long, env = "CARGO")]
29 pub cargo: PathBuf,
30
31 #[clap(long, env = "RUSTC")]
33 pub rustc: PathBuf,
34
35 #[clap(long)]
37 pub buildifier: Option<PathBuf>,
38
39 #[clap(long)]
41 pub config: PathBuf,
42
43 #[clap(long)]
45 pub splicing_manifest: PathBuf,
46
47 #[clap(long)]
49 pub cargo_lockfile: Option<PathBuf>,
50
51 #[clap(long)]
54 pub cargo_config: Option<PathBuf>,
55
56 #[clap(long, env = "CARGO_BAZEL_REPIN", num_args=0..=1, default_missing_value = "true")]
60 pub repin: Option<CargoUpdateRequest>,
61
62 #[clap(long)]
64 pub metadata: Option<PathBuf>,
65
66 #[clap(long, env = "BAZEL_REAL", default_value = "bazel")]
68 pub bazel: PathBuf,
69
70 #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
73 pub workspace_dir: PathBuf,
74
75 #[clap(long)]
77 pub dry_run: bool,
78
79 #[clap(long)]
84 pub nonhermetic_root_bazel_workspace_dir: Utf8PathBuf,
85}
86
87fn 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
102fn 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
118struct BazelInfo {
120 release: semver::Version,
122
123 output_base: PathBuf,
125}
126
127impl BazelInfo {
128 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 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 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 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 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 let manifest_path = splicer
215 .splice_workspace()
216 .context("Failed to splice workspace")?;
217
218 let cargo_lockfile = generate_lockfile(
220 &manifest_path,
221 &opt.cargo_lockfile,
222 cargo.clone(),
223 &opt.repin,
224 )?;
225
226 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 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 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 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 let context = Context::new(annotations, config.rendering.are_sources_present())?;
260
261 let outputs = Renderer::new(
263 Arc::new(config.rendering.clone()),
264 Arc::new(config.supported_platform_triples),
265 )
266 .render(&context, None)?;
267
268 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 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 let normalized_outputs = normalize_cargo_file_paths(outputs, &opt.workspace_dir);
290
291 let file_names: BTreeSet<PathBuf> = normalized_outputs.keys().cloned().collect();
293
294 write_outputs(normalized_outputs, opt.dry_run).context("Failed writing output files")?;
296
297 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 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}