use std::{ fmt::Display, fs::File, io::{BufReader, BufWriter, Read, Write}, path::Path, process::Command, str::FromStr, }; use anyhow::Result; use semver::Version; #[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 { 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 reader = File::options().read(true).open(path)?; let writer = File::options().write(true).open(path)?; self.bump(reader, writer, 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, reader: R, writer: W, mutator: F) -> Result<()> where R: Read, W: Write, F: Fn(String, &Self) -> Result, { let mut reader = BufReader::new(reader); let mut writer = BufWriter::new(writer); let mut buf = String::new(); reader.read_to_string(&mut buf)?; let buf = mutator(buf, self)?; writer.write_all(buf.as_bytes())?; writer.flush().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) } } /// 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 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()) } /// Utility function for bumping the version in a VSC PKGBUILD file. pub fn vsc_pkgbuild(buf: String, _: &Bump) -> Result { 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) } } pub fn changelog(buf: String, Bump { version: _, next }: &Bump) -> Result { let date = chrono::Utc::now().format("%Y-%m-%d"); let lines: Vec = buf .lines() .flat_map(|line| { if line.starts_with("## [Unreleased]") { vec![ line.to_owned(), "".to_owned(), format!("## [{next}] - {date}"), ] } else if line.starts_with("[Unreleased]: ") { vec![ line.to_owned(), line.replace("[Unreleased]", &format!("[{next}]")) .replace("HEAD", &format!("v{next}")), ] } else { vec![line.to_owned()] } }) .collect(); Ok(lines.join("\n")) } #[cfg(test)] mod test { use similar_asserts::SimpleDiff; use crate::PKG_VER; use super::*; fn setup_test() -> Result { 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)?; let version = PKG_VER.parse().unwrap_or_else(|_| Version::new(0, 1, 0)); Ok(Bump { next: Level::default().bump(&version), version, }) } 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() { let bump = setup_test().unwrap(); 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, cargo).unwrap(); println!("{file}: {bump}"); print_diff(reader, &writer) } #[test] fn test_bump_changelog() { let bump = setup_test().unwrap(); 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() { let bump = setup_test().unwrap(); 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() { let bump = setup_test().unwrap(); 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) } }