1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//! Download official protobuf compiler (protoc) releases with a single command, pegged to the
//! version of your choice.

use anyhow::bail;
use reqwest::StatusCode;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

/// Downloads an official [release] of the protobuf compiler (protoc) and returns the path to it.
///
/// The release archive matching the given `version` will be downloaded, and the protoc binary will
/// be extracted into a subdirectory of `out_dir`. You can choose a `version` from the
/// [release] page, for example "21.2". Don't prefix it with a "v".
///
/// `out_dir` can be anywhere you want, but if calling this function from a build script, you should
/// probably use the `OUT_DIR` env var (which is set by Cargo automatically for build scripts).
///
/// A previously downloaded protoc binary of the correct version will be reused if already present
/// in `out_dir`.
///
/// # Examples:
///
/// ```no_run
/// # use std::env;
/// # use std::path::Path;
/// // From within build.rs...
/// let out_dir = env::var("OUT_DIR").unwrap();
/// let protoc_path = protoc_fetcher::protoc("21.2", Path::new(&out_dir));
/// ```
///
/// If you are using [tonic-build] (or [prost-build]), you can instruct it to use the fetched
/// `protoc` binary by setting the `PROTOC` env var.
///
/// ```no_run
/// # use std::env;
/// # use std::path::Path;
/// # let out_dir = env::var("OUT_DIR").unwrap();
/// # let path_to_my_protos = Path::new("a/b/c");
/// # let protoc_path = protoc_fetcher::protoc("21.2", Path::new(&out_dir)).unwrap();
/// env::set_var("PROTOC", &protoc_path);
/// tonic_build::compile_protos(path_to_my_protos);
/// ```
///
/// [release]: https://github.com/protocolbuffers/protobuf/releases
/// [tonic-build]: https://crates.io/crates/tonic-build
/// [prost-build]: https://crates.io/crates/prost-build
pub fn protoc(version: &str, out_dir: &Path) -> anyhow::Result<PathBuf> {
    let protoc_path = ensure_protoc_installed(version, out_dir)?;

    Ok(protoc_path)
}

/// Checks for an existing protoc of the given version; if not found, then the official protoc
/// release is downloaded and "installed", i.e., the binary is copied from the release archive
/// into the `generated` directory.
fn ensure_protoc_installed(version: &str, install_dir: &Path) -> anyhow::Result<PathBuf> {
    let release_name = get_protoc_release_name(version);

    let protoc_dir = install_dir.join(format!("protoc-fetcher/{release_name}"));
    let protoc_path = protoc_dir.join("bin/protoc");
    if protoc_path.exists() {
        println!("protoc with correct version is already installed.");
    } else {
        println!("protoc v{version} not found, downloading...");
        download_protoc(&protoc_dir, &release_name, version)?;
    }
    println!(
        "`protoc --version`: {}",
        get_protoc_version(&protoc_path).unwrap()
    );

    Ok(protoc_path)
}

fn download_protoc(protoc_dir: &Path, release_name: &str, version: &str) -> anyhow::Result<()> {
    let archive_url = protoc_release_archive_url(release_name, version);
    let response = reqwest::blocking::get(archive_url)?;
    if response.status() != StatusCode::OK {
        bail!(
            "Error downloading release archive: {} {}",
            response.status(),
            response.text().unwrap_or_default()
        );
    }
    println!("Download successful.");

    fs::create_dir_all(protoc_dir)?;
    let cursor = Cursor::new(response.bytes()?);
    zip_extract::extract(cursor, protoc_dir, false)?;
    println!("Extracted archive.");

    let protoc_path = protoc_dir.join("bin/protoc");
    if !protoc_path.exists() {
        bail!("Extracted protoc archive, but could not find bin/protoc!");
    }

    println!("protoc installed successfully: {:?}", &protoc_path);
    Ok(())
}

fn protoc_release_archive_url(release_name: &str, version: &str) -> String {
    let archive_url =
        format!("https://github.com/protocolbuffers/protobuf/releases/download/v{version}/{release_name}.zip");
    println!("Release URL: {archive_url}");

    archive_url
}

fn get_protoc_release_name(version: &str) -> String {
    let mut platform = env::consts::OS;
    let mut arch = env::consts::ARCH;
    println!("Detected: {}, {}", platform, arch);

    // Adjust values to match the protoc release names. Examples:
    //   - linux 64-bit: protoc-21.2-linux-x86_64.zip
    //   - macos ARM: protoc-21.2-osx-aarch_64.zip
    if platform == "macos" {
        platform = "osx"; // protoc is stuck in the past XD
    }
    if arch == "aarch64" {
        arch = "aarch_64";
    }

    format!("protoc-{version}-{platform}-{arch}")
}

fn get_protoc_version(protoc_path: &Path) -> anyhow::Result<String> {
    let version = String::from_utf8(Command::new(&protoc_path).arg("--version").output()?.stdout)?;
    Ok(version)
}