use anyhow::Error;
use cargo_toml::Manifest;
use clap::ArgMatches;
use std::convert::TryInto;
use std::fs::read_to_string;
use std::fs::{remove_file, File};
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone)]
pub struct Version {
major: u8,
minor: u8,
patch: u8,
}
impl Version {
pub fn bump(&mut self, semver: SemVer) {
match semver {
SemVer::Major => {
self.major += 1;
self.minor = 0;
self.patch = 0;
}
SemVer::Minor => {
self.minor += 1;
self.patch = 0;
}
SemVer::Patch => self.patch += 1,
};
}
}
#[derive(Debug, Clone)]
pub enum SemVer {
Minor,
Major,
Patch,
}
impl TryInto<SemVer> for &str {
type Error = Error;
fn try_into(self) -> Result<SemVer, Error> {
let semver = match self {
"minor" => SemVer::Minor,
"major" => SemVer::Major,
"patch" => SemVer::Patch,
_ => return Err(Error::msg(format!("Invalid option: {:?}", self))),
};
Ok(semver)
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl TryInto<Version> for String {
type Error = Error;
fn try_into(self) -> Result<Version, Self::Error> {
let version = self
.split('.')
.map(|v| v.parse())
.collect::<Result<Vec<u8>, std::num::ParseIntError>>()?;
if version.len() < 3 {
return Err(Error::msg(format!("Invalid version number: {:?}", version)));
}
Ok(Version {
major: version[0],
minor: version[1],
patch: version[2],
})
}
}
#[derive(Debug, Clone)]
pub struct Manager {
semver: SemVer,
target_branch: String,
current_branch: String,
workspaces: Vec<String>,
check: bool,
fix: bool,
warn: bool,
force: bool,
commit: bool,
dir: PathBuf,
}
impl Manager {
pub fn new(args: &ArgMatches) -> Result<Self, Error> {
let dir = std::env::current_dir()?;
Ok(Self {
dir: dir.clone(),
semver: args.value_of("semver").unwrap_or("minor").try_into()?,
check: args.is_present("check"),
fix: args.is_present("fix"),
warn: args.is_present("warn"),
force: args.is_present("force"),
commit: args.is_present("commit"),
target_branch: args.value_of("branch").unwrap_or("master").to_string(),
current_branch: Self::get_current_branch()?,
workspaces: Self::get_cargo_workspaces(dir)?,
})
}
pub fn get_current_branch() -> Result<String, Error> {
let output = Command::new("git")
.args(&["branch", "--show-current"])
.output()?;
if !output.status.success() {
panic!("Failed to find current branch; ensure you're on git version >2.22");
}
let branch = std::str::from_utf8(&output.stdout)?;
Ok(branch.to_string())
}
pub fn get_cargo_workspaces(dir: PathBuf) -> Result<Vec<String>, Error> {
let mut cargo_toml = dir;
cargo_toml.push("Cargo.toml");
if !cargo_toml.exists() {
panic!("`cargo cvm` must be run in a directory containing a `Cargo.toml` file.\nFile does not exist at: {:?}", cargo_toml.display())
}
let config: Manifest = toml::from_str(&read_to_string(&cargo_toml)?)?;
let mut paths: Vec<String> = Vec::new();
if config.package.is_some() {
let dir = std::env::current_dir()?;
if let Some(path) = dir.to_str() {
paths.push(String::from(path));
}
}
if let Some(workspace) = config.workspace {
paths.extend(workspace.members.into_iter())
}
Ok(paths)
}
pub fn bump_version(&self, workspace: PathBuf) -> Result<(), Error> {
let mut cargo_toml = workspace.clone();
cargo_toml.push("Cargo.toml");
let config = read_to_string(&cargo_toml)?;
if let Some(pkg) = toml::from_str::<Manifest>(&config)?.package {
let old_version: Version = pkg.version.try_into()?;
let mut new_version = old_version.clone();
new_version.bump(self.semver.clone());
let updated_config =
config.replacen(&old_version.to_string(), &new_version.to_string(), 1);
remove_file(&cargo_toml)?;
let mut file = File::create(&cargo_toml)?;
file.write_all(updated_config.as_bytes())?;
Self::git_add_version_update(cargo_toml, new_version.to_string())?;
Ok(())
} else {
panic!("invalid cargo file");
}
}
pub fn git_add_version_update(cargo_toml: PathBuf, version: String) -> Result<(), Error> {
Command::new("git")
.args(&["add", &cargo_toml.display().to_string()])
.output()
.expect("Failed to add updated config");
println!("version {} update added to git.", version);
Ok(())
}
pub fn check_workspaces(&self) -> Result<(), Error> {
let mut failed = false;
for workspace in self.workspaces.iter() {
if self.is_workspace_updated(PathBuf::from(workspace))? {
if let Some(version) = Self::get_workspace_version(PathBuf::from(workspace))? {
if !self.is_workspace_version_updated(PathBuf::from(workspace))? {
let mut cargo_toml = PathBuf::from(workspace);
cargo_toml.push("Cargo.toml".to_string());
let msg = format!(
"version {} is not updated for changes in workspace Cargo.toml file: {:?}",
version, cargo_toml);
if self.check {
eprintln!("{}", msg.clone());
failed = true;
} else if self.fix {
self.bump_version(PathBuf::from(workspace))?;
} else if self.warn {
eprintln!("{}", &msg);
} else {
println!("{}", &msg);
}
} else if self.force {
self.bump_version(PathBuf::from(workspace))?;
}
}
}
}
if failed {
panic!("One or more workspace versions are out of date");
}
if (self.commit && self.fix) || (self.commit && self.force) {
let commit_msg = format!("updated crate version(s)");
Command::new("git")
.args(&["commit", "-m", &commit_msg])
.output()
.expect("Failed to add updated crate versions");
}
Ok(())
}
pub fn is_workspace_updated(&self, workspace: PathBuf) -> Result<bool, Error> {
let mut src_dir = workspace;
src_dir.push("src");
if !src_dir.exists() || !src_dir.is_dir() {
panic!("src directory does not exist at {:?}", src_dir.display())
}
let compare = format!("{}..{}", self.target_branch, self.current_branch);
let args = &[
"diff",
&compare.trim(),
"--",
&src_dir.display().to_string(),
];
let output = Command::new("git").args(args).output()?;
if !output.status.success() {
panic!("Command failed: `git {:?}`", args);
}
let changes = std::str::from_utf8(&output.stdout)?;
Ok(!changes.is_empty())
}
pub fn get_workspace_version(workspace: PathBuf) -> Result<Option<String>, Error> {
let mut cargo_toml = workspace;
cargo_toml.push("Cargo.toml");
let config: Manifest = toml::from_str(&read_to_string(&cargo_toml)?)?;
Ok(config.package.map(|pkg| pkg.version))
}
pub fn is_workspace_version_updated(&self, workspace: PathBuf) -> Result<bool, Error> {
let mut cargo_toml = workspace;
cargo_toml.push("Cargo.toml");
if !cargo_toml.exists() || !cargo_toml.is_file() {
panic!(
"Cargo.toml file does not exist at {:?}",
cargo_toml.display()
)
}
let compare = format!("{}..{}", self.target_branch, self.current_branch);
let args = &[
"diff",
&compare.trim(),
"--",
&cargo_toml.display().to_string(),
];
let output = Command::new("git").args(args).output()?;
if !output.status.success() {
panic!("Command failed: `git {:?}`", args);
}
let changes = String::from(std::str::from_utf8(&output.stdout)?);
Ok(changes.contains("+version ="))
}
}