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 BuildWasm {
27 #[arg(long, required = true)]
29 out_dir: String,
30
31 #[arg(long, required = true, value_enum)]
33 target: Target,
34
35 #[arg(long)]
37 rust_version: Option<String>,
38 },
39
40 BuildKimchiStubs {
42 #[arg(long)]
44 target_dir: Option<String>,
45
46 #[arg(long, short, action, default_value_t = false)]
47 offline: bool,
48 },
49
50 Release {
52 #[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 Nodejs,
69 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 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 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 refresh_poseidon_test_vectors()?;
145
146 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 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 #[cfg(not(target_arch = "x86_64"))]
292 let cpu_supports_adx_bmi2 = false;
293
294 let rustflags = match (optimisations_enabled, cpu_supports_adx_bmi2) {
297 (true, true) => {
298 Some("-C target-feature=+bmi2,+adx".to_string())
301 }
302 (false, true) => {
303 Some("-C target-feature=-bmi2,-adx".to_string())
306 }
307 (true, false) => {
308 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 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}