From ae8dd8c6e5c419f91b9a9ca4e270862b18c5c599 Mon Sep 17 00:00:00 2001 From: Toby Vincent Date: Sun, 4 Jun 2023 14:15:51 -0500 Subject: build: improve ergonomics of version replace --- xtask/src/release/bump.rs | 290 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 xtask/src/release/bump.rs (limited to 'xtask/src/release/bump.rs') diff --git a/xtask/src/release/bump.rs b/xtask/src/release/bump.rs new file mode 100644 index 0000000..617cfd6 --- /dev/null +++ b/xtask/src/release/bump.rs @@ -0,0 +1,290 @@ +use std::{ + fmt::Display, + fs::File, + io::{Read, Write}, + path::Path, + process::Command, + str::FromStr, +}; + +use anyhow::Result; +use semver::Version; + +use crate::PKG_VER; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + Major, + Minor, + #[default] + Patch, +} + +impl Level { + pub fn bump(&self, version: &Version) -> Version { + match self { + Self::Major => Version::new(version.major + 1, 0, 0), + Self::Minor => Version::new(version.major, version.minor + 1, 0), + Self::Patch => Version::new(version.major, version.minor, version.patch + 1), + } + } +} + +impl FromStr for Level { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().as_str() { + "major" => Ok(Level::Major), + "minor" => Ok(Level::Minor), + "patch" => Ok(Level::Patch), + s => Err(anyhow::anyhow!("Invalid bump level: {s}")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Bump { + pub version: Version, + pub next: Version, +} + +impl Bump { + pub fn new(major: u64, minor: u64, patch: u64, level: Level) -> Self { + let version = Version::new(major, minor, patch); + let next = level.bump(&version); + Self { version, next } + } + + fn check_modified

(path: P) -> Result + where + P: AsRef, + { + Ok(Command::new("git") + .arg("diff-index") + .arg("--quiet") + .arg("HEAD") + .arg(path.as_ref()) + .output()? + .status + .success()) + } + + pub fn bump_file(&mut self, path: P, mutator: F) -> Result<()> + where + P: AsRef, + F: Fn(String, &Self) -> Result, + { + let path = path.as_ref(); + + anyhow::ensure!( + Self::check_modified(path)?, + "{} has uncommited changes. Aborting.", + path.display() + ); + + let file = File::open(path)?; + + self.bump(&file, &file, mutator)?; + + let git_added = Command::new("git").arg("add").arg(path).status()?; + + anyhow::ensure!(git_added.success(), "Failed to add bumped files to git"); + + Ok(()) + } + + fn bump(&self, mut reader: R, mut writer: W, mutator: F) -> Result<()> + where + R: Read, + W: Write, + F: Fn(String, &Self) -> Result, + { + let mut buf = String::new(); + reader.read_to_string(&mut buf)?; + + let buf = mutator(buf, self)?; + + writer.write_all(buf.as_bytes()).map_err(Into::into) + } +} + +impl Display for Bump { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} -> {}", self.version, self.next) + } +} + +impl Default for Bump { + fn default() -> Self { + let version = PKG_VER.parse().unwrap_or_else(|_| Version::new(0, 1, 0)); + Self { + next: Level::default().bump(&version), + version, + } + } +} + +impl From for Bump { + fn from(level: Level) -> Self { + let mut v = Self::default(); + v.next = level.bump(&v.version); + v + } +} + +/// Utility function for replacing version with next in a string. +pub fn replace(buf: String, Bump { version, next }: &Bump) -> Result { + Ok(buf.replace(&version.to_string(), &next.to_string())) +} + +/// Utility function for bumping the version in a Cargo.toml file. +pub fn replace_cargo(buf: String, Bump { version: _, next }: &Bump) -> Result { + let mut cargo_toml: toml_edit::Document = buf.parse()?; + + if cargo_toml["package"]["version"]["workspace"] + .as_bool() + .unwrap_or_default() + { + cargo_toml["workspace"]["package"]["version"] = toml_edit::value(next.to_string()); + } else if cargo_toml["package"]["version"].is_str() { + cargo_toml["package"]["version"] = toml_edit::value(next.to_string()); + } else { + anyhow::bail!("Failed to find version in Cargo.toml"); + }; + + Ok(cargo_toml.to_string()) +} + +#[cfg(test)] +mod test { + + use similar_asserts::SimpleDiff; + + use super::*; + + fn setup_test() -> Result<(), std::io::Error> { + let project_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("Failed to get parent directory.") + .to_path_buf(); + + std::env::set_current_dir(project_root) + } + + fn print_diff(reader: &[u8], writer: &[u8]) { + let left = std::str::from_utf8(reader).unwrap(); + let right = std::str::from_utf8(writer).unwrap(); + let diff = SimpleDiff::from_str(left, right, "old", "new"); + + assert_ne!(left, right); + println!("{diff}") + } + + #[test] + fn test_bump_cargo() { + setup_test().unwrap(); + + let bump = Bump::default(); + let file = "Cargo.toml"; + + let content = std::fs::read_to_string(file).unwrap(); + let reader = content.as_bytes(); + let mut writer = Vec::new(); + + bump.bump(reader, &mut writer, replace_cargo).unwrap(); + + println!("{file}: {bump}"); + print_diff(reader, &writer) + } + + #[test] + fn test_bump_changelog() { + setup_test().unwrap(); + + let bump = Bump::default(); + let file = "CHANGELOG.md"; + + let content = std::fs::read_to_string(file).unwrap(); + let reader = content.as_bytes(); + let mut writer = Vec::new(); + + bump.bump(reader, &mut writer, |buf, Bump { version: _, next }| { + let date = chrono::Utc::now().format("%Y-%m-%d"); + Ok(buf + .replace( + "## [Unreleased]", + &format!( + "## [Unreleased]\n\n\ + ## [{next}] - {date}" + ), + ) + .replace( + "[Unreleased]: https://git.sr.ht/~tobyvin/projectr/log/HEAD", + &format!( + "[Unreleased]: https://git.sr.ht/~tobyvin/projectr/log/HEAD\n\ + [{next}]: https://git.sr.ht/~tobyvin/projectr/log/v{next}" + ), + )) + }) + .unwrap(); + + println!("{file}: {bump}"); + print_diff(reader, &writer) + } + + #[test] + fn test_bump_readme() { + setup_test().unwrap(); + + let bump = Bump::default(); + let file = "README.md"; + + let content = std::fs::read_to_string(file); + let reader = content.as_deref().unwrap().as_bytes(); + let mut writer = Vec::new(); + + bump.bump(reader, &mut writer, replace).unwrap(); + + println!("{file}: {bump}"); + print_diff(reader, &writer) + } + + #[test] + fn test_bump_vsc_pkgbuild() { + setup_test().unwrap(); + + let bump = Bump::default(); + let file = "pkg/archlinux/projectr-git/PKGBUILD"; + + let content = std::fs::read_to_string(file).unwrap(); + let reader = content.as_bytes(); + let mut writer = Vec::new(); + + bump.bump(reader, &mut writer, |buf, _| { + let stdout = std::process::Command::new("git") + .arg("describe") + .arg("--long") + .arg("--abbrev=7") + .output()? + .stdout; + + let pkgver = std::str::from_utf8(&stdout)? + .trim() + .trim_start_matches('v') + .replacen("-g", ".g", 1) + .replacen('-', "-r", 1) + .replace('-', "."); + + if let Some(from) = buf.lines().find(|l| l.starts_with("pkgver=")) { + Ok(buf.replace(from, &format!("pkgver={pkgver}"))) + } else { + Ok(buf) + } + }) + .unwrap(); + + println!("{file}: {bump}"); + print_diff(reader, &writer) + } +} -- cgit v1.2.3-70-g09d2