main/
main.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::fmt;
11use std::io::{Read, Write};
12use std::path::Path;
13use std::sync::Arc;
14
15use anyhow::Context;
16use cargo_gazelle::BazelBuildFile;
17use cargo_gazelle::args::Args;
18use cargo_gazelle::config::{CrateConfig, GlobalConfig};
19use cargo_gazelle::context::CrateContext;
20use cargo_gazelle::header::BazelHeader;
21use cargo_gazelle::targets::{
22    CargoBuildScript, ExtractCargoLints, RustBinary, RustLibrary, RustTarget, RustTest,
23};
24use cargo_toml::Manifest;
25use clap::Parser;
26use guppy::graph::{BuildTargetId, PackageMetadata};
27use md5::{Digest, Md5};
28use tracing_subscriber::EnvFilter;
29
30fn main() -> Result<(), anyhow::Error> {
31    tracing_subscriber::fmt()
32        .with_env_filter(EnvFilter::from_default_env())
33        .init();
34
35    let args = Args::try_parse()?;
36    tracing::debug!(?args, "Running with Args");
37    let path = args.path;
38
39    if args.formatter.is_none() {
40        tracing::warn!("Skipping formatting of BUILD.bazel files");
41    }
42    if args.check {
43        tracing::warn!("Running in 'check' mode, won't generate any updates.");
44    }
45
46    let mut command = guppy::MetadataCommand::new();
47    command.manifest_path(&path);
48
49    // Note: In the past we've seen the way metadata gets generated to change between Cargo
50    // versions which introduces skew with how BUILD.bazel files are generated.
51    if let Some(cargo_binary) = &args.cargo {
52        command.cargo_path(cargo_binary);
53    }
54
55    let graph = command.build_graph().context("building crate graph")?;
56    let manifest = Manifest::from_path(&path).context("reading manifest")?;
57
58    // Generate for either a single package, or an entire workspace.
59    let packages: Box<dyn Iterator<Item = PackageMetadata>> = match manifest.package {
60        Some(package) => {
61            let package_metadata = graph.workspace().member_by_name(package.name)?;
62            Box::new(std::iter::once(package_metadata))
63        }
64        None => Box::new(graph.workspace().iter()),
65    };
66
67    let config = Arc::new(GlobalConfig::default());
68
69    std::thread::scope(|s| {
70        let mut handles = Vec::new();
71
72        // Process all of the build files in parallel.
73        for package in packages {
74            let config = Arc::clone(&config);
75            let formatter = args.formatter.clone();
76
77            let handle = s.spawn(move || {
78                let Some(bazel_build) = generage_build_bazel(&config, &package)? else {
79                    return Ok::<_, anyhow::Error>(None);
80                };
81                let bazel_build_str = bazel_build.to_string();
82
83                // Useful when iterating.
84                // println!("{bazel_build}");
85
86                // Place the BUILD.bazel file next to the Cargo.toml file.
87                let crate_path = package.manifest_path().parent().ok_or_else(|| {
88                    anyhow::anyhow!("Should have at least a Cargo.toml component")
89                })?;
90                let build_path = crate_path.join("BUILD.bazel").into_std_path_buf();
91
92                // Write to a temp file that we'll swap into place.
93                let mut temp_file = tempfile::NamedTempFile::new().context("creating tempfile")?;
94                tracing::debug!(?temp_file, "Writing BUILD.bazel file");
95                temp_file
96                    .write_all(bazel_build_str.as_bytes())
97                    .context("writing temp file")?;
98                temp_file.flush().context("flushing temp file")?;
99
100                // Format the generated build file, if a formatter is provided.
101                if let Some(formatter_exec) = &formatter {
102                    let result = std::process::Command::new(formatter_exec)
103                        .args(["-type", "build"])
104                        .arg(temp_file.path())
105                        .output()
106                        .context("executing formatter")?;
107                    if !result.status.success() {
108                        let msg = String::from_utf8_lossy(&result.stderr[..]);
109                        anyhow::bail!("failed to format {build_path:?}, err: {msg}");
110                    }
111                } else {
112                    tracing::debug!(?crate_path, "skipping formatting");
113                }
114
115                let temp_file_hash = hash_file(temp_file.path()).context("hash temp file")?;
116                let existing_hash = hash_file(&build_path).context("hashing existing file")?;
117
118                // If the file didn't change then there's no reason to swap it in.
119                if temp_file_hash == existing_hash {
120                    tracing::debug!(?build_path, "didn't change, skipping");
121                    let _ = temp_file.close();
122                    Ok(None)
123                } else {
124                    Ok(Some((temp_file, build_path)))
125                }
126            });
127            handles.push(handle);
128        }
129
130        // Collect all of the results, bailing if any fail.
131        let results: Vec<_> = handles
132            .into_iter()
133            .map(|handle| handle.join().expect("failed to join!"))
134            .collect::<Result<_, anyhow::Error>>()
135            .context("genrating a BUILD.bazel file")?;
136
137        let updates: Vec<_> = results.into_iter().filter_map(|x| x).collect();
138
139        if args.check && !updates.is_empty() {
140            // If we're in 'check' mode and have changes, then report an error.
141            let mut changes = Vec::new();
142            for (temp_file, dst_path) in updates {
143                let _ = temp_file.close();
144                changes.push(dst_path);
145            }
146            Err(anyhow::anyhow!(
147                "Generated files would have changed:\n{changes:?}"
148            ))
149        } else {
150            // If everything succeeded then swap all of our files into their final path.
151            for (temp_file, dst_path) in updates {
152                // Note: Moving this file into place isn't atomic because some
153                // of our CI jobs run across multiple volumes where atomic
154                // moves are not possible.
155                let (_file, temp_path) = temp_file.keep().context("keeping temp file")?;
156                std::fs::copy(temp_path, dst_path).context("copying over temp file")?;
157            }
158            Ok::<_, anyhow::Error>(())
159        }
160    })?;
161
162    Ok(())
163}
164
165/// Given the [`PackageMetadata`] for a single crate, generates a `BUILD.bazel` file.
166fn generage_build_bazel<'a>(
167    config: &'a GlobalConfig,
168    package: &'a PackageMetadata<'a>,
169) -> Result<Option<BazelBuildFile>, anyhow::Error> {
170    let crate_config = CrateConfig::new(package);
171    tracing::debug!(?crate_config, "found config");
172    if crate_config.skip_generating() {
173        let msg = format!(
174            "skipping generation of '{}' because `skip_generating = True` was set",
175            package.manifest_path()
176        );
177        log_info(msg);
178        return Ok(None);
179    }
180
181    let additive_content = crate_config.additive_content();
182
183    tracing::info!(path = ?package.manifest_path(), "generating");
184
185    let error_context = format!("generating {}", package.name());
186    let crate_context =
187        CrateContext::generate(config, &crate_config, package).context(error_context)?;
188
189    let build_script = CargoBuildScript::generate(config, &crate_context, &crate_config, package)?;
190    let library = RustLibrary::generate(config, package, &crate_config, build_script.as_ref())?;
191    let integration_tests: Vec<_> = package
192        .build_targets()
193        .filter(|target| matches!(target.id(), BuildTargetId::Test(_)))
194        .map(|target| RustTest::integration(config, package, &crate_config, &target))
195        .collect::<Result<_, _>>()?;
196    let binaries: Vec<_> = package
197        .build_targets()
198        .filter(|target| matches!(target.id(), BuildTargetId::Binary(_)))
199        .map(|target| RustBinary::generate(config, package, &crate_config, &target))
200        .collect::<Result<_, _>>()?;
201    let lints = ExtractCargoLints::generate();
202
203    #[allow(clippy::as_conversions)]
204    let targets: Vec<Box<dyn RustTarget>> = [Box::new(library) as Box<dyn RustTarget>]
205        .into_iter()
206        .chain(
207            build_script
208                .into_iter()
209                .map(|t| Box::new(t) as Box<dyn RustTarget>),
210        )
211        .chain(
212            integration_tests
213                .into_iter()
214                .map(|t| Box::new(t) as Box<dyn RustTarget>),
215        )
216        .chain(
217            binaries
218                .into_iter()
219                .map(|t| Box::new(t) as Box<dyn RustTarget>),
220        )
221        .chain(additive_content.map(|t| Box::new(t) as Box<dyn RustTarget>))
222        .chain(std::iter::once(lints).map(|t| Box::new(t) as Box<dyn RustTarget>))
223        .collect();
224
225    Ok(Some(BazelBuildFile {
226        header: BazelHeader::generate(&targets[..]),
227        targets,
228    }))
229}
230
231/// Returns an [`md5`] hash of a file, returning `None` if the specified `path` doesn't exist.
232fn hash_file(file: &Path) -> Result<Option<Vec<u8>>, anyhow::Error> {
233    if !file.exists() {
234        return Ok(None);
235    }
236
237    let file = std::fs::File::open(file).context("openning file")?;
238    let mut reader = std::io::BufReader::new(file);
239    let mut contents = Vec::new();
240    reader.read_to_end(&mut contents).context("reading file")?;
241
242    let mut file_hasher = Md5::new();
243    file_hasher.update(&contents[..]);
244    let file_hash = file_hasher.finalize();
245
246    Ok(Some(file_hash.to_vec()))
247}
248
249/// Prints to stderr an info line that will _always_ get shown to the user.
250fn log_info(s: impl fmt::Display) {
251    static YELLOW_START: &str = "\x1b[94m";
252    static RESET: &str = "\x1b[0m";
253    eprintln!("{YELLOW_START}info:{RESET} {s}");
254}