//! See . //! //! This binary defines various auxiliary build commands, which are not //! expressible with just `cargo`. //! //! This binary is integrated into the `cargo` command line by using an alias in //! `.cargo/config`. use std::{fs::File, io::Write, process::Command}; use std::{ io::{BufReader, BufWriter, Read}, path::{Path, PathBuf}, }; use anyhow::{anyhow, bail, ensure, Context, Result}; use build_info::BuildInfo; use clap::{Parser, Subcommand, ValueEnum}; use flate2::{write::GzEncoder, Compression}; use once_cell::sync::Lazy; use semver::{Prerelease, Version}; use tar::Builder; const PKG_NAME: &str = "projectr"; const PKG_VER: &str = env!("CARGO_PKG_VERSION"); const PKG_INCLUDE: &[&str] = &[ "bin/tmux-projectr", "CONTRIBUTING.md", "README.md", "LICENSE", ]; fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::OutDir => println!("{}", out_dir()?.display()), Commands::Version => match version(cli.force) { Ok(v) => println!("{v}"), Err(_) => std::process::exit(1), }, Commands::Dist => { let version = version(cli.force)?; let targz = generate_tar_gz(version)?; println!("{}", targz.display()); } Commands::Bump { level, pre_release } => { let prev = version(cli.force)?; let next = bump(prev, level, pre_release)?; println!("{next}"); } }; Ok(()) } #[derive(Debug, Clone, Parser)] #[command(author, version, about)] struct Cli { /// Disable version/git tag check #[arg(short, long, global = true)] force: bool, #[command(subcommand)] command: Commands, } #[derive(Debug, Clone, Subcommand)] enum Commands { /// Print the default value of OUT_DIR used by cargo when building the package. OutDir, /// Validate a git tag matching the package version exists and print version. Version, /// Generate distributable package. Dist, /// Bump the version and update version dependant locations in files. Bump { /// SemVer field to increment. #[arg(value_enum, required = false)] level: Level, /// Set pre-release field. #[arg(short, long)] pre_release: Option, }, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] pub enum Level { Major, Minor, #[default] Patch, } impl std::str::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}")), } } } fn version(force: bool) -> Result { use build_info::VersionControl::Git; let version: Version = PKG_VER.parse()?; let BuildInfo { version_control: Some(Git(git)), .. } = build_info() else { bail!("Failed to get version control info."); }; if force || git.tags.contains(&format!("v{version}")) { Ok(version) } else { Err(anyhow!("Failed to find git tag matching package version.")) } } fn out_dir() -> Result { RELEASE_DIR .join("build") .read_dir() .context("Failed to read build directory.")? .flatten() .filter_map(|d| { d.file_name() .to_str()? .starts_with(PKG_NAME) .then(|| d.path().join("invoked.timestamp")) .filter(|p| p.exists()) }) .reduce(|acc, path_buf| { std::cmp::max_by_key(path_buf, acc, |p| { p.metadata() .and_then(|m| m.modified()) .unwrap_or(std::time::SystemTime::UNIX_EPOCH) }) }) .map(|p| p.with_file_name("out")) .filter(|o| o.exists()) .context("Failed to find `out` directory for latest build") } fn generate_tar_gz(version: Version) -> Result { let target = build_info::format!("{}", $.target.triple); let dist_pkg = DIST_DIR.join(format!("{PKG_NAME}-v{version}-{target}.tar.gz")); let binary = build_binary()?; ensure!(binary.exists(), "Failed to find package binary",); let _ = std::fs::remove_dir_all(&*DIST_DIR); std::fs::create_dir_all(&*DIST_DIR)?; let tar_gz = File::create(&dist_pkg)?; let enc = GzEncoder::new(tar_gz, Compression::default()); let mut tar = Builder::new(enc); tar.append_path_with_name(binary, PathBuf::from("bin").join(PKG_NAME))?; tar.append_dir_all(".", out_dir()?)?; PKG_INCLUDE.iter().try_for_each(|p| tar.append_path(p))?; tar.into_inner()?.finish()?; Ok(dist_pkg) } fn build_binary() -> Result { let status = Command::new("cargo") .arg("build") .arg("--release") .arg(format!("--package={PKG_NAME}")) .status() .context("Failed to invoke `cargo build`")?; anyhow::ensure!(status.success(), "Cargo returned an error"); let mut binary = RELEASE_DIR.join(PKG_NAME); if cfg!(windows) { binary.set_extension("exe"); }; if let Err(e) = Command::new("strip").arg(&binary).status() { eprintln!("Failed to strip the binary: {}", e) } Ok(binary) } fn bump(prev: Version, level: Level, pre_release: Option) -> Result { let mut next = prev.clone(); match level { Level::Major => next.major += 1, Level::Minor => next.minor += 1, Level::Patch => next.patch += 1, }; if let Some(pre) = pre_release { next.pre = pre; } bump_cargo(&next)?; bump_readme(&prev, &next)?; bump_changelog(&next)?; let cargo_update = Command::new("cargo") .arg("update") .arg("--workspace") .status()?; anyhow::ensure!(cargo_update.success(), "Failed to update cargo lockfile"); let git_added = Command::new("git") .arg("add") .arg("./Cargo.lock") .status()?; anyhow::ensure!(git_added.success(), "Failed to add Cargo.lock to git"); Ok(next) } fn bump_readme(prev: &Version, next: &Version) -> Result<()> { bump_file("./README.md", |contents| { Ok(contents.replace(&prev.to_string(), &next.to_string())) }) } fn bump_changelog(next: &Version) -> Result<()> { bump_file("./CHANGELOG.md", |contents| { let date = chrono::Utc::now().format("%Y-%m-%d"); let lines: Vec = contents .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")) }) } fn bump_cargo(next: &Version) -> Result<()> { bump_file("./Cargo.toml", |contents| { let mut cargo_toml: toml_edit::Document = contents.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()) }) } fn bump_file(path: P, mutator: F) -> Result<()> where P: AsRef, F: Fn(String) -> Result, { let path = path.as_ref(); let mut reader = BufReader::new(File::open(path)?); let mut buf = String::new(); reader.read_to_string(&mut buf)?; let buf = mutator(buf)?; let mut writer = BufWriter::new(File::open(path)?); writer.write_all(buf.as_bytes())?; writer.flush()?; let git_added = Command::new("git").arg("add").arg(path).status()?; anyhow::ensure!(git_added.success(), "Failed to add bumped files to git"); Ok(()) } static PROJECT_ROOT: Lazy = Lazy::new(|| { let dir = std::env::current_dir().unwrap_or_else(|_| { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .to_path_buf() }); dir.ancestors() .find(|p| p.join(".git").is_dir()) .unwrap_or(&dir) .to_path_buf() }); static DIST_DIR: Lazy = Lazy::new(|| PROJECT_ROOT.join("target").join("dist")); static RELEASE_DIR: Lazy = Lazy::new(|| PROJECT_ROOT.join("target").join("release")); build_info::build_info!(fn build_info);