Skip to main content

xtask/
main.rs

1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand, ValueEnum};
3#[cfg(target_arch = "x86_64")]
4use raw_cpuid::CpuId;
5use semver::Version;
6use std::{
7    env,
8    ffi::OsString,
9    fs,
10    ops::{Deref, DerefMut},
11    path::PathBuf,
12    process::Command,
13};
14use toml_edit::{value, DocumentMut};
15
16#[derive(Parser)]
17#[command(author, version, about, long_about = None)]
18struct Cli {
19    #[command(subcommand)]
20    command: Commands,
21}
22
23#[derive(Subcommand)]
24enum Commands {
25    /// Build WASM package
26    BuildWasm {
27        /// Output directory for wasm-pack
28        #[arg(long, required = true)]
29        out_dir: String,
30
31        /// Target platform (nodejs or web)
32        #[arg(long, required = true, value_enum)]
33        target: Target,
34
35        /// Version of `rustc`
36        #[arg(long)]
37        rust_version: Option<String>,
38    },
39
40    /// Build kimchi-stubs with optional CPU optimisations
41    BuildKimchiStubs {
42        /// Target directory for cargo build artifacts
43        #[arg(long)]
44        target_dir: Option<String>,
45
46        #[arg(long, short, action, default_value_t = false)]
47        offline: bool,
48    },
49
50    /// Release a new version
51    Release {
52        /// Bump type
53        #[arg(value_enum)]
54        bump: BumpType,
55    },
56}
57
58#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
59enum BumpType {
60    Patch,
61    Minor,
62    Major,
63}
64
65#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)]
66enum Target {
67    /// Build for NodeJS
68    Nodejs,
69    /// Build for Web
70    Web,
71}
72
73impl From<Target> for &'static str {
74    fn from(target: Target) -> &'static str {
75        match target {
76            Target::Nodejs => "nodejs",
77            Target::Web => "web",
78        }
79    }
80}
81
82fn main() -> Result<()> {
83    let cli = Cli::parse();
84
85    match &cli.command {
86        Commands::BuildWasm {
87            out_dir,
88            target,
89            rust_version,
90        } => build_wasm(out_dir, *target, rust_version.as_deref()),
91        Commands::BuildKimchiStubs {
92            target_dir,
93            offline,
94        } => build_kimchi_stubs(target_dir.as_deref(), *offline),
95        Commands::Release { bump } => release(*bump),
96    }
97}
98
99fn release(bump: BumpType) -> Result<()> {
100    // 1. Bump version in Cargo.toml
101    let cargo_toml_path = "Cargo.toml";
102    let cargo_toml_content =
103        fs::read_to_string(cargo_toml_path).context("Failed to read Cargo.toml")?;
104    let mut doc = cargo_toml_content
105        .parse::<DocumentMut>()
106        .context("Failed to parse Cargo.toml")?;
107
108    let version_str = doc["workspace"]["package"]["version"]
109        .as_str()
110        .context("version not found in [workspace.package]")?
111        .to_string();
112    let mut version = Version::parse(&version_str).context("Failed to parse version")?;
113
114    match bump {
115        BumpType::Patch => version.patch += 1,
116        BumpType::Minor => {
117            version.minor += 1;
118            version.patch = 0;
119        }
120        BumpType::Major => {
121            version.major += 1;
122            version.minor = 0;
123            version.patch = 0;
124        }
125    }
126
127    let new_version = version.to_string();
128    doc["workspace"]["package"]["version"] = value(&new_version);
129    fs::write(cargo_toml_path, doc.to_string()).context("Failed to write Cargo.toml")?;
130
131    println!("Bumping version from {} to {}", version_str, new_version);
132
133    // 2. Update CHANGELOG.md
134    let changelog_path = "CHANGELOG.md";
135    let changelog_content =
136        fs::read_to_string(changelog_path).context("Failed to read CHANGELOG.md")?;
137    let new_changelog_content = changelog_content.replace(
138        "## Unreleased",
139        &format!("## Unreleased\n\n## {}", new_version),
140    );
141    fs::write(changelog_path, new_changelog_content).context("Failed to write CHANGELOG.md")?;
142
143    // 3. Regenerate poseidon test vectors snapshots
144    refresh_poseidon_test_vectors()?;
145
146    // 4. Update Cargo.lock
147    println!("Updating Cargo.lock...");
148    let status = Command::new("cargo")
149        .arg("check")
150        .status()
151        .context("Failed to update Cargo.lock")?;
152    if !status.success() {
153        anyhow::bail!("cargo check failed");
154    }
155
156    println!("Release preparation for version {} complete!", new_version);
157
158    Ok(())
159}
160
161fn refresh_poseidon_test_vectors() -> Result<()> {
162    println!("Validating and refreshing poseidon export_test_vectors snapshots...");
163
164    let cases = [
165        ("b10", "legacy", "json", false),
166        ("b10", "kimchi", "json", false),
167        ("hex", "legacy", "json", false),
168        ("hex", "kimchi", "json", false),
169        ("b10", "legacy", "es5", true),
170        ("b10", "kimchi", "es5", true),
171        ("hex", "legacy", "es5", true),
172        ("hex", "kimchi", "es5", true),
173    ];
174
175    let mut updates = Vec::new();
176
177    for (mode, param_type, format, deterministic) in cases {
178        let extension = if format == "json" { "json" } else { "js" };
179        let output_file = format!(
180            "poseidon/export_test_vectors/test_vectors/{}_{}.{}",
181            mode, param_type, extension
182        );
183
184        let generated = generate_vectors_to_stdout(mode, param_type, format, deterministic)?;
185        let expected = fs::read_to_string(&output_file)
186            .with_context(|| format!("Failed to read expected file: {output_file}"))?;
187
188        let generated_trimmed = generated.trim();
189        let expected_trimmed = expected.trim();
190
191        if format == "json" {
192            if generated_trimmed != expected_trimmed {
193                anyhow::bail!(
194                    "Poseidon vectors changed unexpectedly in {output_file}. \
195Run dedicated vector updates in a separate PR and review carefully."
196                );
197            }
198        } else if normalize_es5_header_version(generated_trimmed)
199            != normalize_es5_header_version(expected_trimmed)
200        {
201            anyhow::bail!(
202                "Poseidon ES5 vector body changed unexpectedly in {output_file} \
203(ignoring version header). Run dedicated vector updates in a separate PR and review carefully."
204            );
205        }
206
207        updates.push((output_file, generated));
208    }
209
210    for (output_file, generated) in updates {
211        fs::write(&output_file, generated)
212            .with_context(|| format!("Failed to write refreshed vectors file: {output_file}"))?;
213    }
214
215    Ok(())
216}
217
218fn generate_vectors_to_stdout(
219    mode: &str,
220    param_type: &str,
221    format: &str,
222    deterministic: bool,
223) -> Result<String> {
224    let mut cmd = Command::new("cargo");
225    cmd.args([
226        "run",
227        "--bin",
228        "export_test_vectors",
229        "--",
230        mode,
231        param_type,
232        "-",
233        "--format",
234        format,
235    ]);
236    if deterministic {
237        cmd.arg("--deterministic");
238    }
239
240    let output = cmd.output().with_context(|| {
241        format!(
242            "Failed to run export_test_vectors for mode={mode}, param_type={param_type}, format={format}"
243        )
244    })?;
245    if !output.status.success() {
246        let stderr = String::from_utf8_lossy(&output.stderr);
247        anyhow::bail!(
248            "export_test_vectors failed for mode={mode}, param_type={param_type}, format={format}: {stderr}"
249        );
250    }
251
252    String::from_utf8(output.stdout).with_context(|| {
253        format!(
254            "export_test_vectors produced non-UTF8 output for mode={mode}, param_type={param_type}, format={format}"
255        )
256    })
257}
258
259fn normalize_es5_header_version(content: &str) -> String {
260    content
261        .lines()
262        .map(|line| {
263            if line.starts_with("// Generated by export_test_vectors ") {
264                "// Generated by export_test_vectors <VERSION>".to_string()
265            } else {
266                line.to_string()
267            }
268        })
269        .collect::<Vec<_>>()
270        .join("\n")
271}
272
273type RustVersion<'a> = Option<&'a str>;
274
275fn build_kimchi_stubs(target_dir: Option<&str>, offline: bool) -> Result<()> {
276    // Optimisations are enabled by default, but can be disabled by setting the
277    // `RUST_TARGET_FEATURE_OPTIMISATIONS` environment variable to any other
278    // value than "y".
279    let optimisations_enabled = env::var("RUST_TARGET_FEATURE_OPTIMISATIONS")
280        .map(|v| ["y", "1", "true"].contains(&v.to_lowercase().as_str()))
281        .unwrap_or(true);
282
283    #[cfg(target_arch = "x86_64")]
284    let cpu_supports_adx_bmi2 = {
285        let cpuid = CpuId::new();
286        cpuid
287            .get_extended_feature_info()
288            .is_some_and(|f| f.has_adx() && f.has_bmi2())
289    };
290    // ADX and BMI2 are not applicable to other architectures.
291    #[cfg(not(target_arch = "x86_64"))]
292    let cpu_supports_adx_bmi2 = false;
293
294    // If optimisations are enabled and the CPU supports ADX and BMI2, we enable
295    // those features.
296    let rustflags = match (optimisations_enabled, cpu_supports_adx_bmi2) {
297        (true, true) => {
298            // If optimisations are enabled and the CPU supports ADX and BMI2,
299            // we enable them.
300            Some("-C target-feature=+bmi2,+adx".to_string())
301        }
302        (false, true) => {
303            // If optimisations are disabled but the CPU supports ADX and BMI2,
304            // we explicitly disable them.
305            Some("-C target-feature=-bmi2,-adx".to_string())
306        }
307        (true, false) => {
308            // If the CPU does not support ADX and BMI2, we do not set any
309            // target features. It could be handled in the `else` branch, but we
310            // want to be explicit. If the CPU does not support these features, but
311            // we still add the -bmi2 and -adx flags, it will cause a build warning
312            // we want to avoid on the user console.
313            None
314        }
315        (false, false) => None,
316    };
317
318    let target_dir = target_dir.unwrap_or("target/kimchi_stubs_build");
319
320    let mut cmd = Command::new("cargo");
321    cmd.args([
322        "build",
323        "--release",
324        "-p",
325        "kimchi-stubs",
326        "--target-dir",
327        target_dir,
328    ]);
329
330    if offline {
331        cmd.arg("--offline");
332    }
333
334    if let Some(rustflags) = rustflags {
335        cmd.env("RUSTFLAGS", rustflags);
336    }
337
338    let status = cmd.status().context("Failed to build kimchi-stubs")?;
339
340    if !status.success() {
341        anyhow::bail!("kimchi-stubs build failed");
342    }
343
344    Ok(())
345}
346
347fn build_wasm(out_dir: &str, target: Target, rust_version: RustVersion) -> Result<()> {
348    let cargo_target_dir = env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
349    let artifact_dir = PathBuf::from(format!("{cargo_target_dir}/bin"));
350
351    let mut cmd = RustVersionCommand::for_cargo(rust_version);
352
353    let args = [
354        "build",
355        "--release",
356        "--package=wasm-pack",
357        "--bin=wasm-pack",
358        "--artifact-dir",
359        artifact_dir.to_str().unwrap(),
360        "-Z=unstable-options",
361    ];
362
363    let status = cmd
364        .args(args)
365        .env("CARGO_TARGET_DIR", &cargo_target_dir)
366        .status()
367        .context("Failed to build wasm-pack")?;
368
369    if !status.success() {
370        anyhow::bail!("wasm-pack build failed");
371    }
372
373    let wasm_pack_path = artifact_dir.join("wasm-pack");
374    let mut cmd = RustVersionCommand::for_wasm_pack(wasm_pack_path, rust_version);
375
376    // Prepare the command arguments
377    let args = [
378        "build",
379        "--target",
380        target.into(),
381        "--out-dir",
382        out_dir,
383        "plonk-wasm",
384        "--",
385        "-Z",
386        "build-std=panic_abort,std",
387    ];
388
389    let target_args: &[_] = if target == Target::Nodejs {
390        &["--features", "nodejs"]
391    } else {
392        &[]
393    };
394
395    let status = cmd
396        .args(args)
397        .args(target_args)
398        .status()
399        .context("Failed to execute wasm-pack")?;
400
401    if !status.success() {
402        anyhow::bail!("wasm-pack build for {} failed", <&str>::from(target));
403    }
404
405    Ok(())
406}
407
408struct RustVersionCommand<'a> {
409    cmd: Command,
410    rustup_args: Option<(OsString, &'a str)>,
411}
412
413impl<'a> RustVersionCommand<'a> {
414    fn for_cargo(rustup_args: Option<&'a str>) -> Self {
415        let (cmd, rustup_args) = if let Some(version) = rustup_args {
416            (
417                Command::new("rustup"),
418                Some((OsString::from("cargo"), version)),
419            )
420        } else {
421            (Command::new("cargo"), None)
422        };
423
424        Self { cmd, rustup_args }
425    }
426
427    fn for_wasm_pack(wasm_path: PathBuf, rustup_args: Option<&'a str>) -> Self {
428        let (cmd, rustup_args) = if let Some(version) = rustup_args {
429            let cmd = Command::new("rustup");
430            let rustup_args = Some((wasm_path.into_os_string(), version));
431
432            (cmd, rustup_args)
433        } else {
434            (Command::new(wasm_path), None)
435        };
436
437        Self { cmd, rustup_args }
438    }
439}
440
441impl Deref for RustVersionCommand<'_> {
442    type Target = Command;
443
444    fn deref(&self) -> &Self::Target {
445        &self.cmd
446    }
447}
448
449impl DerefMut for RustVersionCommand<'_> {
450    fn deref_mut(&mut self) -> &mut Self::Target {
451        let Some((program, version)) = self.rustup_args.take() else {
452            return &mut self.cmd;
453        };
454
455        self.cmd.arg("run").arg(version).arg(program)
456    }
457}