use crate::{ConventionalCommits, Error, Level, Semantic, TypeHierarchy};
use git2::Repository;
use std::{collections::HashSet, ffi::OsString, fmt};
pub fn latest(version_prefix: &str) -> Result<Semantic, Error> {
let repo = Repository::open(".")?;
log::debug!("repo opened to find latest");
let mut versions = vec![];
repo.tag_foreach(|_id, name| {
if let Ok(name) = String::from_utf8(name.to_owned()) {
if let Some(name) = name.strip_prefix("refs/tags/") {
if name.starts_with(version_prefix) {
if let Ok(semantic_version) = Semantic::parse(name, version_prefix) {
log::trace!("found qualifying tag {}", &semantic_version);
versions.push(semantic_version);
}
}
}
}
true
})?;
versions.sort();
log::debug!("versions sorted");
match versions.last().cloned() {
Some(v) => {
log::trace!("latest version found is {}", &v);
Ok(v)
}
None => Err(Error::NoVersionTag),
}
}
#[derive(Debug)]
pub enum ForceLevel {
Major,
Minor,
Patch,
}
impl fmt::Display for ForceLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ForceLevel::Major => write!(f, "major"),
ForceLevel::Minor => write!(f, "minor"),
ForceLevel::Patch => write!(f, "patch"),
}
}
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct VersionCalculator {
current_version: Semantic,
conventional: Option<ConventionalCommits>,
files: Option<HashSet<OsString>>,
}
impl VersionCalculator {
pub fn new(version_prefix: &str) -> Result<VersionCalculator, Error> {
let current_version = latest(version_prefix)?;
Ok(VersionCalculator {
current_version,
conventional: None,
files: None,
})
}
pub fn name(&self) -> Semantic {
self.current_version.clone()
}
pub fn top_level(&self) -> Option<TypeHierarchy> {
if self.conventional.is_none() {
None
} else {
self.conventional.clone().unwrap().top_type()
}
}
pub fn count_commits_by_type(&self, commit_type: &str) -> u32 {
match self.conventional.clone() {
Some(conventional) => conventional
.counts()
.get(commit_type)
.unwrap_or(&0_u32)
.to_owned(),
None => 0_u32,
}
}
pub fn breaking(&self) -> bool {
match self.conventional.clone() {
Some(conventional) => conventional.breaking(),
None => false,
}
}
pub fn force(&mut self, level: ForceLevel) -> Self {
let mut conventional_commits = ConventionalCommits::new();
log::debug!("forcing a change to {}", level);
match level {
ForceLevel::Major => {
conventional_commits.set_breaking(true);
}
ForceLevel::Minor => {
conventional_commits.increment_counts(git_conventional::Type::FEAT);
}
ForceLevel::Patch => {
conventional_commits.increment_counts(git_conventional::Type::FIX);
}
}
self.conventional = Some(conventional_commits);
self.clone()
}
pub fn walk_commits(mut self) -> Result<Self, Error> {
let repo = git2::Repository::open(".")?;
log::debug!("repo opened to find conventional commits");
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(git2::Sort::NONE)?;
revwalk.push_head()?;
log::debug!("starting the walk from the HEAD");
let glob = format!("refs/tags/{}", &self.current_version);
revwalk.hide_ref(&glob)?;
log::debug!("hide commits from {}", &self.current_version);
macro_rules! filter_try {
($e:expr) => {
match $e {
Ok(t) => t,
Err(e) => return Some(Err(e)),
}
};
}
#[allow(clippy::unnecessary_filter_map)]
let revwalk = revwalk.filter_map(|id| {
let id = filter_try!(id);
let commit = repo.find_commit(id);
let commit = filter_try!(commit);
Some(Ok(commit))
});
let mut conventional_commits = ConventionalCommits::new();
let mut files = HashSet::new();
for commit in revwalk.flatten() {
log::trace!("commit found: {}", &commit.summary().unwrap_or_default());
conventional_commits.push(&commit);
let tree = commit.tree()?;
let diff = repo.diff_tree_to_workdir(Some(&tree), None).unwrap();
diff.print(git2::DiffFormat::NameOnly, |delta, _hunk, _line| {
let file = delta.new_file().path().unwrap().file_name().unwrap();
log::trace!("file found: {:?}", file);
files.insert(file.to_os_string());
true
})
.unwrap();
}
self.conventional = Some(conventional_commits);
log::debug!("Files found: {:#?}", &files);
self.files = Some(files);
Ok(self)
}
pub fn next_version(&mut self) -> (Semantic, Level) {
#[cfg(let_else)]
let Some(conventional) = self.conventional.clone() else {
return (self.current_version.clone(), Level::None)
};
#[cfg(not(let_else))]
let conventional = match self.conventional.clone() {
Some(c) => c,
None => return (self.current_version.clone(), Level::None),
};
let bump = if conventional.breaking() {
log::debug!("breaking change found");
Level::Major
} else if 0 < conventional.commits_by_type("feat") {
log::debug!(
"{} feature commit(s) found requiring increment of minor number",
&conventional.commits_by_type("feat")
);
Level::Minor
} else if 0 < conventional.commits_all_types() {
log::debug!(
"{} conventional commit(s) found requiring increment of patch number",
&conventional.commits_all_types()
);
Level::Patch
} else {
Level::None
};
let final_bump = if self.current_version.major() == 0 {
log::info!("Not yet at a stable version");
match bump {
Level::Major => Level::Minor,
Level::Minor => Level::Patch,
_ => bump,
}
} else {
bump
};
let next_version = next_version_calculator(self.current_version.clone(), &final_bump);
(next_version, final_bump)
}
pub fn promote_first(&mut self) -> Result<(Semantic, Level), Error> {
if 0 < self.current_version.major() {
Err(Error::MajorAlreadyUsed(
self.current_version.major().to_string(),
))
} else {
Ok(self.force(ForceLevel::Major).next_version())
}
}
pub fn has_required(
&self,
files_required: Vec<OsString>,
level: TypeHierarchy,
) -> Result<(), Error> {
if self
.conventional
.as_ref()
.unwrap()
.top_type()
.unwrap_or(TypeHierarchy::Other)
>= level
{
let files = self.files.clone();
if let Some(files) = files {
let mut missing_files = vec![];
for file in files_required {
if !files.contains(&file) {
missing_files.push(file.clone());
}
}
if !missing_files.is_empty() {
return Err(Error::MissingRequiredFile(missing_files));
}
} else {
return Err(Error::NoFilesListed);
}
}
Ok(())
}
}
fn next_version_calculator(mut version: Semantic, bump: &Level) -> Semantic {
match *bump {
Level::Major => version.increment_major().clone(),
Level::Minor => version.increment_minor().clone(),
Level::Patch => version.increment_patch().clone(),
_ => version,
}
}