Files
regorus/xtask/src/tasks/bindings/version.rs
Anand Krishnamoorthi 47124623ab chore: bump version to 0.10.0 across all bindings (#710)
Update regorus core crate and all language bindings (ffi, java, python,
wasm, ruby, csharp) to version 0.10.0.

- Centralize C# package version via Directory.Packages.props
- Remove redundant C# version suffix properties from project files
- Regenerate all binding Cargo.lock files including Ruby
- Fix xtask to read/write RegorusPackageVersion from Directory.Packages.props
  instead of parsing VersionPrefix from the csproj (which now uses an MSBuild
  property indirection)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-06 10:58:58 -05:00

642 lines
22 KiB
Rust

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Keeps every language binding in lockstep with the core `regorus` crate version.
//!
//! The command follows the pattern popularised by large Rust workspaces such as
//! [`rust-analyzer`](https://github.com/rust-lang/rust-analyzer),
//! [`gitoxide`](https://github.com/Byron/gitoxide), and
//! [`ripgrep`](https://github.com/BurntSushi/ripgrep): a dedicated `xtask`
//! binary shells out to Git to detect source changes and rewrites the affected
//! manifests in one shot. That approach gives us deterministic edits (the Rust
//! code owns every write) while piggybacking on Git's knowledge of tracked vs
//! ignored files, so we avoid re-implementing ignore logic or path matching by
//! hand.
// Version management quick reference:
// - Run `cargo xtask bindings` after bumping the root crate; untouched bindings align to the core (e.g. root 0.5.2 pulls a quiet binding from 0.5.1 → 0.5.2).
// - Binding edits trigger a SemVer "minor" bump even while major = 0: 0.5.1+changes → 0.6.0, and 0.5.3+changes still lands on 0.6.0 even if the root sits at 0.5.2.
// - When the root races ahead, we follow it: a clean binding at 0.6.2 syncing against root 0.7.0 ends up at 0.7.0.
// - Use `cargo xtask bindings --check` in CI or pre-commit to fail fast when any manifest would be rewritten.
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use clap::Args;
use regex::Regex;
use semver::{BuildMetadata, Prerelease, Version};
use toml_edit::{value, DocumentMut};
/// CLI entry point for `cargo xtask bindings`.
#[derive(Args)]
pub struct BindingsCommand {
/// Override the version applied to bindings (defaults to regorus crate version)
#[arg(long, value_name = "VERSION")]
new_version: Option<String>,
/// Compare binding sources against this git ref (defaults to merge-base with origin/main)
#[arg(long, value_name = "REF")]
base_ref: Option<String>,
/// Return an error instead of writing files when updates are needed
#[arg(long)]
check: bool,
}
impl BindingsCommand {
/// Executes the bindings update workflow and reports any edits performed.
pub fn run(&self) -> Result<()> {
let workspace_root = workspace_root();
let root_version = match self.new_version.as_deref() {
Some(explicit) => Version::parse(explicit)
.with_context(|| format!("invalid --new-version '{explicit}'"))?,
None => read_root_crate_version(&workspace_root)?,
};
let watch_dirs = collect_watch_dirs();
let changed_paths =
collect_changed_paths(&workspace_root, self.base_ref.as_deref(), &watch_dirs)?;
let mut touched_paths = Vec::new();
let mut summaries = Vec::new();
for binding in BINDINGS {
let binding_changed = binding_has_changes(binding, &changed_paths);
let current_version = read_binding_version(&workspace_root, binding)?;
let desired_version = desired_version(&current_version, &root_version, binding_changed);
let mut files =
apply_binding_version(&workspace_root, binding, &desired_version, self.check)?;
let files_modified = !files.is_empty();
touched_paths.append(&mut files);
let version_changed = desired_version != current_version;
summaries.push(BindingSummary {
name: binding.name,
desired_version,
version_changed,
binding_changed,
files_modified,
});
}
if !self.check {
if touched_paths.is_empty() {
println!("Bindings already up to date (target {}).", root_version);
} else {
println!("Updated binding versions:");
for summary in summaries.into_iter().filter(|s| s.files_modified) {
let reason = if summary.binding_changed && summary.version_changed {
"binding changes detected"
} else if summary.version_changed {
"aligned with core version"
} else {
"metadata normalized"
};
println!(
" {} -> {} ({reason})",
summary.name, summary.desired_version
);
}
}
}
Ok(())
}
}
/// Captures what happened for a single binding during a run.
struct BindingSummary {
name: &'static str,
desired_version: Version,
version_changed: bool,
binding_changed: bool,
files_modified: bool,
}
/// Static description of each binding and the artefacts that should be updated.
struct Binding {
name: &'static str,
watch: &'static [&'static str],
manifest: Option<&'static str>,
ruby_version: Option<&'static str>,
pom_xml: Option<&'static str>,
csharp_version_file: Option<&'static str>,
csharp_dependents: &'static [&'static str],
}
/// Shared empty slice to keep the binding declarations uncluttered.
const EMPTY: &[&str] = &[];
/// Declarative inventory of the bindings we keep in sync.
const BINDINGS: &[Binding] = &[
Binding {
name: "ffi",
watch: &["bindings/ffi"],
manifest: Some("bindings/ffi/Cargo.toml"),
ruby_version: None,
pom_xml: None,
csharp_version_file: None,
csharp_dependents: EMPTY,
},
Binding {
name: "java",
watch: &["bindings/java"],
manifest: Some("bindings/java/Cargo.toml"),
ruby_version: None,
pom_xml: Some("bindings/java/pom.xml"),
csharp_version_file: None,
csharp_dependents: EMPTY,
},
Binding {
name: "python",
watch: &["bindings/python"],
manifest: Some("bindings/python/Cargo.toml"),
ruby_version: None,
pom_xml: None,
csharp_version_file: None,
csharp_dependents: EMPTY,
},
Binding {
name: "wasm",
watch: &["bindings/wasm"],
manifest: Some("bindings/wasm/Cargo.toml"),
ruby_version: None,
pom_xml: None,
csharp_version_file: None,
csharp_dependents: EMPTY,
},
Binding {
name: "ruby",
watch: &["bindings/ruby"],
manifest: Some("bindings/ruby/ext/regorusrb/Cargo.toml"),
ruby_version: Some("bindings/ruby/lib/regorus/version.rb"),
pom_xml: None,
csharp_version_file: None,
csharp_dependents: EMPTY,
},
Binding {
name: "csharp",
watch: &["bindings/csharp"],
manifest: None,
ruby_version: None,
pom_xml: None,
csharp_version_file: Some("bindings/csharp/Directory.Packages.props"),
csharp_dependents: EMPTY,
},
];
/// Returns the workspace root (one level above this crate).
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask resides in workspace root")
.to_path_buf()
}
/// Collects the unique set of directories whose contents we monitor for changes.
/// Collects the unique set of directories whose contents we monitor for changes.
fn collect_watch_dirs() -> Vec<&'static str> {
let mut seen = HashSet::new();
BINDINGS
.iter()
.flat_map(|binding| binding.watch.iter().copied())
.filter(|dir| seen.insert(*dir))
.collect()
}
/// Returns every binding-relative path that changed compared to the base
/// revision as well as unstaged or untracked files in the working tree.
fn collect_changed_paths(
root: &Path,
base_ref: Option<&str>,
watch_dirs: &[&'static str],
) -> Result<HashSet<String>> {
let mut paths = HashSet::new();
if !watch_dirs.is_empty() {
if let Some(base) = resolve_base_ref(root, base_ref)? {
let mut args: Vec<&str> = vec!["diff", "--name-only", base.as_str()];
args.push("--");
args.extend(watch_dirs.iter().copied());
let diff = run_git(root, args)?;
for line in diff.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
paths.insert(trimmed.to_string());
}
}
}
let mut status_args: Vec<&str> = vec!["status", "--porcelain"]; // covers tracked + untracked
status_args.push("--");
status_args.extend(watch_dirs.iter().copied());
let status = run_git(root, status_args)?;
for line in status.lines() {
if line.len() < 4 {
continue;
}
let details = line[3..].trim();
if details.is_empty() {
continue;
}
let path = details.split(" -> ").last().unwrap().to_string();
paths.insert(path);
}
}
Ok(paths)
}
/// Picks the git commit to diff against: explicit flag, merge-base with
/// `origin/main`, then the immediate parent commit as a fallback.
fn resolve_base_ref(root: &Path, explicit: Option<&str>) -> Result<Option<String>> {
if let Some(reference) = explicit {
return Ok(Some(reference.to_string()));
}
if let Ok(output) = run_git(root, ["merge-base", "HEAD", "origin/main"]) {
if let Some(line) = output.lines().next() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
}
if let Ok(output) = run_git(root, ["rev-parse", "HEAD^"]) {
if let Some(line) = output.lines().next() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return Ok(Some(trimmed.to_string()));
}
}
}
Ok(None)
}
/// Runs `git` with the supplied arguments and returns stdout as UTF-8 text.
fn run_git<I, S>(root: &Path, args: I) -> Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.current_dir(root)
.args(args)
.output()
.context("failed to invoke git")?;
if !output.status.success() {
bail!(
"git command failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
String::from_utf8(output.stdout).context("git output was not valid UTF-8")
}
/// Checks whether any watched path for this binding changed.
fn binding_has_changes(binding: &Binding, changed_paths: &HashSet<String>) -> bool {
changed_paths.iter().any(|path| {
let path_ref = Path::new(path);
binding
.watch
.iter()
.any(|prefix| path_ref.starts_with(Path::new(prefix)))
})
}
/// Reads the version declared in the root `Cargo.toml`.
fn read_root_crate_version(root: &Path) -> Result<Version> {
let manifest = fs::read_to_string(root.join("Cargo.toml"))
.context("failed to read workspace Cargo.toml")?;
let doc = manifest
.parse::<DocumentMut>()
.context("failed to parse workspace Cargo.toml")?;
let version = doc["package"]["version"]
.as_str()
.ok_or_else(|| anyhow!("workspace Cargo.toml missing package.version"))?;
Version::parse(version)
.with_context(|| format!("invalid workspace package.version '{version}'"))
}
/// Reads the authoritative version for a binding (Cargo manifest or C# project).
fn read_binding_version(root: &Path, binding: &Binding) -> Result<Version> {
if let Some(manifest) = binding.manifest {
return read_manifest_version(&root.join(manifest));
}
if let Some(project) = binding.csharp_version_file {
return read_csharp_version(root.join(project));
}
bail!("binding '{}' missing version source", binding.name)
}
fn read_manifest_version(path: &Path) -> Result<Version> {
let contents =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let doc = contents
.parse::<DocumentMut>()
.with_context(|| format!("failed to parse {}", path.display()))?;
let version = doc["package"]["version"]
.as_str()
.ok_or_else(|| anyhow!("{} missing package.version", path.display()))?;
Version::parse(version)
.with_context(|| format!("invalid version '{}' in {}", version, path.display()))
}
fn read_csharp_version(path: PathBuf) -> Result<Version> {
let contents =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let re = Regex::new(r#"(?s)<RegorusPackageVersion>(?P<value>[^<]+)</RegorusPackageVersion>"#)?;
let caps = re
.captures(&contents)
.ok_or_else(|| anyhow!("{} missing <RegorusPackageVersion> entry", path.display()))?;
let version = caps.name("value").unwrap().as_str().trim();
Version::parse(version).with_context(|| {
format!(
"invalid RegorusPackageVersion '{}' in {}",
version,
path.display()
)
})
}
/// Calculates the version to write, bumping the minor release when the binding
/// changed while never regressing below the current version.
fn desired_version(current: &Version, target: &Version, binding_changed: bool) -> Version {
let mut candidate = target.clone();
if binding_changed {
candidate.minor += 1;
candidate.patch = 0;
candidate.pre = Prerelease::EMPTY;
candidate.build = BuildMetadata::EMPTY;
}
if candidate < *current {
candidate = current.clone();
}
candidate
}
/// Applies the chosen version across every artefact owned by the binding.
fn apply_binding_version(
root: &Path,
binding: &Binding,
version: &Version,
check: bool,
) -> Result<Vec<String>> {
let mut touched = Vec::new();
let version_str = version.to_string();
if let Some(manifest) = binding.manifest {
if update_manifest_version(root, manifest, &version_str, check)? {
touched.push(manifest.to_string());
// Update Cargo.lock if it exists in the same directory
if let Some(parent) = root.join(manifest).parent() {
let lock_path = parent.join("Cargo.lock");
if lock_path.exists() && !check {
update_cargo_lock(root, manifest)?;
}
}
}
}
if let Some(version_file) = binding.ruby_version {
if update_ruby_version(root, version_file, &version_str, check)? {
touched.push(version_file.to_string());
}
}
if let Some(pom_path) = binding.pom_xml {
if update_java_pom(root, pom_path, &version_str, check)? {
touched.push(pom_path.to_string());
}
}
if let Some(project) = binding.csharp_version_file {
touched.extend(update_csharp_version_files(
root,
project,
binding.csharp_dependents,
&version_str,
check,
)?);
}
Ok(touched)
}
/// Updates a Cargo manifest with the supplied version string.
fn update_manifest_version(
root: &Path,
manifest: &str,
version: &str,
check: bool,
) -> Result<bool> {
let path = root.join(manifest);
edit_file(&path, check, |contents| {
let mut doc = contents
.parse::<DocumentMut>()
.with_context(|| format!("failed to parse {}", path.display()))?;
let current = doc["package"]["version"]
.as_str()
.ok_or_else(|| anyhow!("{} missing package.version", path.display()))?;
if current == version {
return Ok(None);
}
doc["package"]["version"] = value(version);
Ok(Some(doc.to_string()))
})
}
/// Rewrites the Ruby gem version constant.
fn update_ruby_version(
root: &Path,
version_file: &str,
version: &str,
check: bool,
) -> Result<bool> {
let path = root.join(version_file);
edit_file(&path, check, |contents| {
let re = Regex::new(r#"(?m)^(?P<prefix>\s*VERSION\s*=\s*")(?P<value>[^"]+)(?P<suffix>")"#)?;
if let Some(caps) = re.captures(contents) {
if caps.name("value").unwrap().as_str() == version {
return Ok(None);
}
let updated = re.replace(contents, |caps: &regex::Captures| {
format!("{}{}{}", &caps["prefix"], version, &caps["suffix"])
});
Ok(Some(updated.into_owned()))
} else {
bail!("{} missing VERSION constant", path.display());
}
})
}
/// Updates the Maven `pom.xml` entry for the Java binding.
fn update_java_pom(root: &Path, pom_path: &str, version: &str, check: bool) -> Result<bool> {
let path = root.join(pom_path);
edit_file(&path, check, |contents| {
let re = Regex::new(
r#"(?s)(<artifactId>regorus-java</artifactId>\s*<version>)(?P<value>[^<]+)(</version>)"#,
)?;
if let Some(caps) = re.captures(contents) {
if caps.name("value").unwrap().as_str() == version {
return Ok(None);
}
let updated = re.replace(contents, |caps: &regex::Captures| {
format!("{}{}{}", &caps[1], version, &caps[3])
});
Ok(Some(updated.into_owned()))
} else {
bail!("{} missing regorus-java version", path.display());
}
})
}
/// Updates the NuGet packaging project and any sample/test consumers.
fn update_csharp_version_files(
root: &Path,
version_file: &str,
dependent_projects: &[&str],
version: &str,
check: bool,
) -> Result<Vec<String>> {
let mut touched = Vec::new();
let version_prop = Regex::new(
r#"(?s)(?P<prefix><RegorusPackageVersion>)(?P<value>[^<]+)(?P<suffix></RegorusPackageVersion>)"#,
)?;
let package_path = root.join(version_file);
if !version_prop.is_match(
&fs::read_to_string(&package_path)
.with_context(|| format!("failed to read {}", package_path.display()))?,
) {
anyhow::bail!(
"{} missing <RegorusPackageVersion> entry",
package_path.display()
);
}
if edit_file(&package_path, check, |contents| {
let mut changed = false;
let new_contents = version_prop
.replace(contents, |caps: &regex::Captures| {
let current = caps.name("value").unwrap().as_str().trim();
if current == version {
caps[0].to_string()
} else {
changed = true;
format!("{}{}{}", &caps["prefix"], version, &caps["suffix"])
}
})
.into_owned();
if changed && new_contents != *contents {
Ok(Some(new_contents))
} else {
Ok(None)
}
})? {
touched.push(version_file.to_string());
}
let pkg_ref = Regex::new(
r#"(?i)(?P<prefix><Package(?:Reference|Version)[^>]*Include="microsoft\.regorus"[^>]*Version=")(?P<value>\d+\.\d+\.\d+)(?P<suffix>[^\"]*")"#,
)?;
for rel_path in dependent_projects {
let path = root.join(rel_path);
if edit_file(&path, check, |contents| {
let mut changed = false;
let replaced = pkg_ref.replace_all(contents, |caps: &regex::Captures| {
let current = caps.name("value").unwrap().as_str();
if current == version {
caps[0].to_string()
} else {
changed = true;
format!("{}{}{}", &caps["prefix"], version, &caps["suffix"])
}
});
let new_contents = replaced.into_owned();
if changed && new_contents != *contents {
Ok(Some(new_contents))
} else {
Ok(None)
}
})? {
touched.push((*rel_path).to_string());
}
}
touched.sort();
touched.dedup();
Ok(touched)
}
/// Reads a file, applies a transformation, and writes it back unless `--check`
/// is set.
fn edit_file<F>(path: &Path, check: bool, apply: F) -> Result<bool>
where
F: FnOnce(&str) -> Result<Option<String>>,
{
let original =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
if let Some(updated) = apply(&original)? {
if check {
bail!("{} requires updates", path.display());
}
fs::write(path, updated).with_context(|| format!("failed to write {}", path.display()))?;
return Ok(true);
}
Ok(false)
}
/// Updates Cargo.lock after a manifest version change by running cargo update.
/// Uses --precise to only update the version entry without touching dependencies.
fn update_cargo_lock(root: &Path, manifest: &str) -> Result<()> {
let manifest_path = root.join(manifest);
// Read the package name and version from the manifest
let contents = fs::read_to_string(&manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let doc = contents
.parse::<DocumentMut>()
.with_context(|| format!("failed to parse {}", manifest_path.display()))?;
let package_name = doc["package"]["name"]
.as_str()
.ok_or_else(|| anyhow!("{} missing package.name", manifest_path.display()))?;
let version = doc["package"]["version"]
.as_str()
.ok_or_else(|| anyhow!("{} missing package.version", manifest_path.display()))?;
let output = Command::new("cargo")
.arg("update")
.arg("--package")
.arg(package_name)
.arg("--precise")
.arg(version)
.arg("--manifest-path")
.arg(&manifest_path)
.output()
.context("failed to run cargo update")?;
if !output.status.success() {
bail!(
"cargo update failed for {}: {}",
manifest_path.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}