summaryrefslogtreecommitdiffstats
path: root/xtask/src/bump.rs
diff options
context:
space:
mode:
Diffstat (limited to 'xtask/src/bump.rs')
-rw-r--r--xtask/src/bump.rs325
1 files changed, 325 insertions, 0 deletions
diff --git a/xtask/src/bump.rs b/xtask/src/bump.rs
new file mode 100644
index 0000000..da54e23
--- /dev/null
+++ b/xtask/src/bump.rs
@@ -0,0 +1,325 @@
+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<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 prev: Version,
+ pub next: Version,
+}
+
+impl Bump {
+ 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>(&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 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<R, W, F>(&self, reader: R, writer: W, mutator: F) -> Result<()>
+ where
+ R: Read,
+ W: Write,
+ F: Fn(String, &Self) -> Result<String>,
+ {
+ 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.prev, self.next)
+ }
+}
+
+/// Utility function for replacing version with next in a string.
+pub fn replace(
+ buf: String,
+ Bump {
+ prev: 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 cargo(buf: String, Bump { prev: _, 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())
+}
+
+/// Utility function for bumping the version in a VSC PKGBUILD file.
+pub fn vsc_pkgbuild(buf: String, _: &Bump) -> Result<String> {
+ 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 { prev: _, next }: &Bump) -> Result<String> {
+ let date = chrono::Utc::now().format("%Y-%m-%d");
+
+ let lines: Vec<String> = 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<Bump, 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)?;
+
+ let version = PKG_VER.parse().unwrap_or_else(|_| Version::new(0, 1, 0));
+ Ok(Bump {
+ next: Level::default().bump(&version),
+ prev: 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 { prev: _, 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)
+ }
+}