aboutsummaryrefslogtreecommitdiffstats
path: root/xtask/src/release/bump.rs
diff options
context:
space:
mode:
Diffstat (limited to 'xtask/src/release/bump.rs')
-rw-r--r--xtask/src/release/bump.rs290
1 files changed, 290 insertions, 0 deletions
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<Self, Self::Err> {
+ 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<P>(path: P) -> Result<bool>
+ where
+ P: AsRef<Path>,
+ {
+ Ok(Command::new("git")
+ .arg("diff-index")
+ .arg("--quiet")
+ .arg("HEAD")
+ .arg(path.as_ref())
+ .output()?
+ .status
+ .success())
+ }
+
+ pub fn bump_file<P, F>(&mut self, path: P, mutator: F) -> Result<()>
+ where
+ P: AsRef<Path>,
+ F: Fn(String, &Self) -> Result<String>,
+ {
+ 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<R, W, F>(&self, mut reader: R, mut writer: W, mutator: F) -> Result<()>
+ where
+ R: Read,
+ W: Write,
+ F: Fn(String, &Self) -> Result<String>,
+ {
+ 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<Level> 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<String> {
+ 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<String> {
+ 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)
+ }
+}