protoc_fetcher/
lib.rs

1//! Download official protobuf compiler (protoc) releases with a single command, pegged to the
2//! version of your choice.
3
4use anyhow::bail;
5use reqwest::StatusCode;
6use std::fs;
7use std::io::Cursor;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Downloads an official [release] of the protobuf compiler (protoc) and returns the path to it.
12///
13/// The release archive matching the given `version` will be downloaded, and the protoc binary will
14/// be extracted into a subdirectory of `out_dir`. You can choose a `version` from the
15/// [release] page, for example "31.1". Don't prefix it with a "v".
16///
17/// `out_dir` can be anywhere you want, but if calling this function from a build script, you should
18/// probably use the `OUT_DIR` env var (which is set by Cargo automatically for build scripts).
19///
20/// A previously downloaded protoc binary of the correct version will be reused if already present
21/// in `out_dir`.
22///
23/// # Examples:
24///
25/// ```no_run
26/// # use std::env;
27/// # use std::path::Path;
28/// // From within build.rs...
29/// let out_dir = env::var("OUT_DIR").unwrap();
30/// let protoc_path = protoc_fetcher::protoc("31.1", Path::new(&out_dir));
31/// ```
32///
33/// If you are using [tonic-build] (or [prost-build]), you can instruct it to use the fetched
34/// `protoc` binary by setting the `PROTOC` env var.
35///
36/// ```no_run
37/// # use std::env;
38/// # use std::path::Path;
39/// # let out_dir = env::var("OUT_DIR").unwrap();
40/// # let path_to_my_protos = Path::new("a/b/c");
41/// # let protoc_path = protoc_fetcher::protoc("31.1", Path::new(&out_dir)).unwrap();
42/// env::set_var("PROTOC", &protoc_path);
43/// tonic_build::compile_protos(path_to_my_protos);
44/// ```
45///
46/// [release]: https://github.com/protocolbuffers/protobuf/releases
47/// [tonic-build]: https://crates.io/crates/tonic-build
48/// [prost-build]: https://crates.io/crates/prost-build
49pub fn protoc(version: &str, out_dir: &Path) -> anyhow::Result<PathBuf> {
50    let protoc_path = ensure_protoc_installed(version, out_dir)?;
51
52    Ok(protoc_path)
53}
54
55/// Checks for an existing protoc of the given version; if not found, then the official protoc
56/// release is downloaded and "installed", i.e., the binary is copied from the release archive
57/// into the `generated` directory.
58fn ensure_protoc_installed(version: &str, install_dir: &Path) -> anyhow::Result<PathBuf> {
59    let release_name = get_protoc_release_name(version);
60
61    let protoc_dir = install_dir.join(format!("protoc-fetcher/{release_name}"));
62    let protoc_path = protoc_dir.join("bin/protoc");
63    if protoc_path.exists() {
64        println!("protoc with correct version is already installed.");
65    } else {
66        println!("protoc v{version} not found, downloading...");
67        download_protoc(&protoc_dir, &release_name, version)?;
68    }
69    println!(
70        "`protoc --version`: {}",
71        get_protoc_version(&protoc_path).unwrap()
72    );
73
74    Ok(protoc_path)
75}
76
77fn download_protoc(protoc_dir: &Path, release_name: &str, version: &str) -> anyhow::Result<()> {
78    let archive_url = protoc_release_archive_url(release_name, version);
79    let response = reqwest::blocking::get(archive_url)?;
80    if response.status() != StatusCode::OK {
81        bail!(
82            "Error downloading release archive: {} {}",
83            response.status(),
84            response.text().unwrap_or_default()
85        );
86    }
87    println!("Download successful.");
88
89    fs::create_dir_all(protoc_dir)?;
90    let cursor = Cursor::new(response.bytes()?);
91    zip_extract::extract(cursor, protoc_dir, false)?;
92    println!("Extracted archive.");
93
94    #[cfg(unix)]
95    let protoc_path = protoc_dir.join("bin/protoc");
96
97    #[cfg(windows)]
98    let protoc_path = protoc_dir.join("bin/protoc.exe");
99
100    if !protoc_path.exists() {
101        bail!("Extracted protoc archive, but could not find bin/protoc!");
102    }
103
104    println!("protoc installed successfully: {:?}", &protoc_path);
105    Ok(())
106}
107
108fn protoc_release_archive_url(release_name: &str, version: &str) -> String {
109    let archive_url =
110        format!("https://github.com/protocolbuffers/protobuf/releases/download/v{version}/{release_name}.zip");
111    println!("Release URL: {archive_url}");
112
113    archive_url
114}
115
116fn get_protoc_release_name(version: &str) -> String {
117    // Adjust values to match the protoc release names. Examples:
118    //   - linux 64-bit: protoc-21.2-linux-x86_64.zip
119    //   - macos ARM: protoc-21.2-osx-aarch_64.zip
120    //   - windows 32-bit: protoc-21.2-win32.zip
121
122    #[allow(unused)]
123    let name = "";
124
125    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
126    let name = "linux-aarch_64";
127
128    #[cfg(all(target_os = "linux", target_arch = "x86"))]
129    let name = "linux-x86_32";
130
131    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
132    let name = "linux-x86_64";
133
134    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
135    let name = "osx-aarch_64";
136
137    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
138    let name = "osx-x86_64";
139
140    #[cfg(all(
141        target_os = "macos",
142        not(target_arch = "aarch64"),
143        not(target_arch = "x86_64")
144    ))]
145    let name = "osx-universal_binary";
146
147    #[cfg(all(windows, target_pointer_width = "32"))]
148    let name = "win32";
149
150    #[cfg(all(windows, target_pointer_width = "64"))]
151    let name = "win64";
152
153    if name.is_empty() {
154        panic!("`protoc` unsupported platform");
155    }
156
157    println!("Detected: {}", name);
158
159    format!("protoc-{version}-{name}")
160}
161
162fn get_protoc_version(protoc_path: &Path) -> anyhow::Result<String> {
163    let version = String::from_utf8(Command::new(protoc_path).arg("--version").output()?.stdout)?;
164    Ok(version)
165}