// 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, /// Compare binding sources against this git ref (defaults to merge-base with origin/main) #[arg(long, value_name = "REF")] base_ref: Option, /// 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(¤t_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> { 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> { 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(root: &Path, args: I) -> Result where I: IntoIterator, S: AsRef, { 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) -> 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 { let manifest = fs::read_to_string(root.join("Cargo.toml")) .context("failed to read workspace Cargo.toml")?; let doc = manifest .parse::() .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 { 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 { let contents = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; let doc = contents .parse::() .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 { let contents = fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; let re = Regex::new(r#"(?s)(?P[^<]+)"#)?; let caps = re .captures(&contents) .ok_or_else(|| anyhow!("{} missing 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> { 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 { let path = root.join(manifest); edit_file(&path, check, |contents| { let mut doc = contents .parse::() .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 { let path = root.join(version_file); edit_file(&path, check, |contents| { let re = Regex::new(r#"(?m)^(?P\s*VERSION\s*=\s*")(?P[^"]+)(?P")"#)?; if let Some(caps) = re.captures(contents) { if caps.name("value").unwrap().as_str() == version { return Ok(None); } let updated = re.replace(contents, |caps: ®ex::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 { let path = root.join(pom_path); edit_file(&path, check, |contents| { let re = Regex::new( r#"(?s)(regorus-java\s*)(?P[^<]+)()"#, )?; if let Some(caps) = re.captures(contents) { if caps.name("value").unwrap().as_str() == version { return Ok(None); } let updated = re.replace(contents, |caps: ®ex::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> { let mut touched = Vec::new(); let version_prop = Regex::new( r#"(?s)(?P)(?P[^<]+)(?P)"#, )?; 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 entry", package_path.display() ); } if edit_file(&package_path, check, |contents| { let mut changed = false; let new_contents = version_prop .replace(contents, |caps: ®ex::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]*Include="microsoft\.regorus"[^>]*Version=")(?P\d+\.\d+\.\d+)(?P[^\"]*")"#, )?; 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: ®ex::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(path: &Path, check: bool, apply: F) -> Result where F: FnOnce(&str) -> Result>, { 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::() .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(()) }