use std::{borrow::Cow, cmp::Ordering, collections::HashMap, io::Write};
use anyhow::Context as _;
use convco::{
changelog::{
ChangelogWriter, CommitContext, CommitGroup, Context, ContextBase, ContextBuilder, Note,
NoteGroup, Reference,
},
commit_type_eq, open_repo, CommitParser, CommitTrait, Config, ConvcoError, Footer, FooterKey,
MaxMajorsIterExt, MaxMinorsIterExt, MaxPatchesIterExt, Repo, RevWalkOptions,
};
use semver::Version;
use crate::{cli::ChangelogCommand, Command};
#[derive(Debug, Clone)]
struct Rev<C> {
commit: Option<C>,
version: Option<Version>,
tag: String,
version_label: Option<String>,
}
struct Unreleased {
str: String,
version: Option<Version>,
}
struct ChangeLogTransformer<'a, R: Repo<'a>> {
group_types: Vec<(&'a str, &'a str)>,
config: &'a Config,
revwalk_options: RevWalkOptions<'a, R::CommitTrait>,
unreleased: Unreleased,
repo: &'a R,
context_builder: ContextBuilder<'a>,
prefix: &'a str,
}
impl<'a, R: Repo<'a>> ChangeLogTransformer<'a, R> {
fn new(
config: &'a Config,
include_hidden_sections: bool,
repo: &'a R,
revwalk_options: RevWalkOptions<'a, R::CommitTrait>,
unreleased: String,
prefix: &'a str,
) -> Result<Self, ConvcoError> {
let group_types = config
.types
.iter()
.filter(|ty| include_hidden_sections || !ty.hidden)
.map(|ty| (ty.r#type.as_str(), ty.section.as_str()))
.collect();
let context_builder = ContextBuilder::new(config)?;
let unreleased = match unreleased.parse::<Version>() {
Ok(version) => Unreleased {
str: unreleased,
version: Some(version),
},
_ => Unreleased {
str: unreleased,
version: None,
},
};
Ok(Self {
config,
group_types,
repo,
revwalk_options,
context_builder,
unreleased,
prefix,
})
}
fn make_notes(&self, footers: &'_ [Footer], scope: Option<String>) -> Vec<(String, Note)> {
footers
.iter()
.filter(|footer| matches!(footer.key, FooterKey::BreakingChange))
.map(|footer| {
(
footer.key.to_string(),
Note {
scope: scope.clone(),
text: footer.value.clone(),
},
)
})
.collect()
}
fn transform(
&self,
to_rev: Rev<R::CommitTrait>,
from_rev: Rev<R::CommitTrait>,
) -> Result<Context<'_>, ConvcoError> {
let revwalk_options = RevWalkOptions {
from_rev: {
let mut rev = self.revwalk_options.from_rev.clone();
if let Some(from_rev) = from_rev.commit.as_ref() {
rev.push(from_rev.clone());
}
rev
},
to_rev: to_rev.commit.as_ref().unwrap().clone(),
..self.revwalk_options.clone()
};
let revwalk = self.repo.revwalk(revwalk_options)?;
let mut commits: HashMap<&str, Vec<CommitContext>> = HashMap::new();
let mut notes: HashMap<String, Vec<Note>> = HashMap::new();
let version_date = self
.repo
.revision_time(&to_rev.tag, to_rev.commit.as_ref().unwrap())?
.date();
let Config {
host,
owner,
repository,
..
} = self.config;
for commit in revwalk.flatten() {
let conv_commit = commit.conventional_commit;
let footers = conv_commit.footers;
self.make_notes(&footers, conv_commit.scope.clone())
.into_iter()
.for_each(|(key, note)| {
notes.entry(key).or_default().push(note);
});
let hash = commit.commit.id();
let date = commit.commit.commit_time()?.date();
let scope = conv_commit.scope;
let subject = conv_commit.description;
let body = conv_commit.body;
let short_hash = hash[..7].into();
let references = conv_commit
.references
.into_iter()
.map(|r| Reference {
action: r.action,
owner: owner.as_deref().unwrap_or_default(),
repository: repository.as_deref().unwrap_or_default(),
prefix: r.prefix,
issue: r.issue,
})
.collect();
let commit_context = CommitContext {
hash,
date,
scope,
subject,
body,
short_hash,
references,
};
if let Some((_, section)) = self
.group_types
.iter()
.find(|(ty, _)| commit_type_eq(ty, &conv_commit.r#type))
{
commits.entry(section).or_default().push(commit_context)
}
}
let version: Cow<str> = if let Some(version) = &to_rev.version {
format!("{}{}", self.prefix, version).into()
} else if let Some(version_label) = &to_rev.version_label {
version_label.clone().into()
} else {
match &self.unreleased.version {
Some(v) => format!("{}{}", self.prefix, v).into(),
None => self.unreleased.str.as_str().into(),
}
};
let is_patch = to_rev
.version
.as_ref()
.map(|i| i.patch != 0)
.unwrap_or(false)
|| (to_rev.version.is_none()
&& self
.unreleased
.version
.as_ref()
.map(|i| i.patch != 0)
.unwrap_or(false));
let mut commit_groups: Vec<CommitGroup<'_>> = commits
.into_iter()
.map(|(title, commits)| CommitGroup { title, commits })
.collect();
commit_groups.sort_by(|a, b| self.sort_commit_groups(a, b));
let note_groups: Vec<NoteGroup> = notes
.into_iter()
.map(|(title, notes)| NoteGroup { title, notes })
.collect();
let context_base = ContextBase {
version,
date: Some(version_date),
is_patch,
commit_groups,
note_groups,
previous_tag: from_rev.tag,
current_tag: to_rev.tag.into(),
host: host.to_owned(),
owner: owner.to_owned(),
repository: repository.to_owned(),
link_compare: self.config.link_compare,
link_references: self.config.link_references,
};
self.context_builder.build(context_base)
}
fn sort_commit_groups(&self, a: &CommitGroup<'_>, b: &CommitGroup<'_>) -> Ordering {
fn find_pos<'a, R: Repo<'a>>(
this: &ChangeLogTransformer<'a, R>,
title: &str,
) -> Option<usize> {
this.config
.types
.iter()
.enumerate()
.find(|(_, x)| x.section == title)
.map(|(i, _)| i)
}
let pos_a = find_pos(self, a.title);
let pos_b = find_pos(self, b.title);
pos_a.cmp(&pos_b)
}
}
impl ChangelogCommand {
pub(crate) fn write(&self, mut config: Config, stdout: impl Write) -> anyhow::Result<()> {
if self.no_links {
config.link_references = false;
config.link_compare = false;
}
if self.merges {
config.merges = true;
}
if self.first_parent {
config.first_parent = true;
}
if let Some(line_length) = self.line_length {
config.line_length = line_length;
}
if self.no_wrap {
config.wrap_disabled = true;
}
let repo = open_repo()?;
let rev_str = self.rev.as_str();
let (rev_high, rev_high_label, rev_low) = match rev_str.split_once("..") {
None => {
let rev_high = Repo::revparse_single(&repo, rev_str)?;
let label = (rev_str != "HEAD").then(|| rev_str.to_owned());
(rev_high, label, None)
}
Some(("", rev)) => {
let rev_high = Repo::revparse_single(&repo, rev)?;
let label = (rev != "HEAD").then(|| rev.to_owned());
(rev_high, label, None)
}
Some((rev_low, "")) => {
let rev_high = Repo::revparse_single(&repo, "HEAD")?;
let rev_low = Repo::revparse_single(&repo, rev_low)?;
(rev_high, None, Some(rev_low))
}
Some((rev_low, rev_high)) => {
let rev_high_label = (rev_high != "HEAD").then(|| rev_high.to_owned());
let rev_high = Repo::revparse_single(&repo, rev_high)?;
let rev_low = Repo::revparse_single(&repo, rev_low)?;
(rev_high, rev_high_label, Some(rev_low))
}
};
let template = config.template.as_deref();
let mut writer = ChangelogWriter::new(template, &config, stdout)?;
writer.write_header(config.header.as_str())?;
let commit_parser = CommitParser::builder()
.scope_regex(config.scope_regex.clone())
.strip_regex(config.strip_regex.clone())
.references_regex(format!("({})([0-9]+)", config.issue_prefixes.join("|")))
.build();
let revwalk_options = RevWalkOptions {
from_rev: rev_low.iter().cloned().collect(),
to_rev: rev_high.clone(),
first_parent: config.first_parent,
no_merge_commits: !config.merges,
no_revert_commits: false, paths: self
.paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect(),
parser: &commit_parser,
};
let transformer = ChangeLogTransformer::new(
&config,
self.include_hidden_sections,
&repo,
revwalk_options,
self.unreleased.clone(),
&self.prefix,
)?;
let semvers = repo.semver_tags(&self.prefix)?;
let tag_high = repo
.find_last_version(&rev_high, self.ignore_prereleases, &semvers)
.with_context(|| {
format!("Could not find the last version for revision {}", &self.rev)
})?;
let tag_low = match &rev_low {
Some(rev_low) => repo.find_last_version(rev_low, self.ignore_prereleases, &semvers)?,
None => None,
};
match tag_high {
Some(tag_high) => {
let all_semvers = semvers.clone();
let mut sem_ver_iter: Box<dyn Iterator<Item = (semver::Version, _)>> =
Box::new(semvers.into_iter().filter(|(version, _)| {
version <= &tag_high.0
&& tag_low
.as_ref()
.is_none_or(|(low_ver, _)| version > low_ver)
}));
if self.max_majors != u64::MAX {
sem_ver_iter = Box::new(sem_ver_iter.max_majors_iter(self.max_majors));
}
if self.max_minors != u64::MAX {
sem_ver_iter = Box::new(sem_ver_iter.max_minors_iter(self.max_minors));
}
if self.max_patches != u64::MAX {
sem_ver_iter = Box::new(sem_ver_iter.max_patches_iter(self.max_patches));
}
let semver_data: Vec<(_, _)> = sem_ver_iter.collect::<Vec<_>>();
let semvers: Vec<Rev<_>> = semver_data
.into_iter()
.map(|(version, commit)| Rev {
tag: format!("{}{}", self.prefix, version),
commit: Some(commit),
version: Some(version),
version_label: None,
})
.collect::<Vec<_>>();
let mut revs = Vec::with_capacity(semvers.len() + 2);
if rev_high.id() != tag_high.1.id() {
revs.push(Rev {
tag: rev_high_label
.clone()
.unwrap_or_else(|| CommitTrait::id(&rev_high)),
commit: Some(rev_high),
version: None,
version_label: rev_high_label.clone(),
});
}
revs.extend(semvers);
if let Some(max_versions) = self.max_versions {
revs.truncate(max_versions);
}
if !revs.is_empty() {
if let Some(ref rev_low_commit) = rev_low {
if let Some((low_ver, low_commit)) = &tag_low {
if low_commit.id() == rev_low_commit.id() {
revs.push(Rev {
tag: format!("{}{}", self.prefix, low_ver),
commit: Some(low_commit.clone()),
version: Some(low_ver.clone()),
version_label: None,
});
} else {
revs.push(Rev {
tag: CommitTrait::id(rev_low_commit),
commit: Some(rev_low_commit.clone()),
version: None,
version_label: None,
});
}
} else {
revs.push(Rev {
tag: CommitTrait::id(rev_low_commit),
commit: Some(rev_low_commit.clone()),
version: None,
version_label: None,
});
}
} else {
let next_tag_below =
if revs.last().and_then(|r| r.version.as_ref()).is_none() {
Some(tag_high.clone())
} else {
revs.last()
.and_then(|r| r.version.as_ref())
.and_then(|last_ver| {
all_semvers.iter().find(|(v, _)| v < last_ver).cloned()
})
};
if let Some((below_ver, below_commit)) = next_tag_below {
revs.push(Rev {
tag: format!("{}{}", self.prefix, below_ver),
commit: Some(below_commit),
version: Some(below_ver),
version_label: None,
});
} else {
revs.push(Rev {
tag: String::new(),
commit: None,
version: None,
version_label: None,
});
}
}
}
for w in revs.windows(2).map(|w| (w[0].clone(), w[1].clone())) {
let context = transformer.transform(w.0, w.1)?;
if !self.skip_empty || !context.context.commit_groups.is_empty() {
writer.write_template(&context)?;
}
}
}
None => {
let context = transformer.transform(
Rev {
tag: rev_high_label
.clone()
.unwrap_or_else(|| CommitTrait::id(&rev_high)),
commit: Some(rev_high),
version: None,
version_label: rev_high_label,
},
Rev {
tag: String::new(),
commit: None,
version: None,
version_label: None,
},
)?;
if !self.skip_empty || !context.context.commit_groups.is_empty() {
writer.write_template(&context)?;
}
}
}
Ok(())
}
}
impl Command for ChangelogCommand {
fn exec(&self, config: Config) -> anyhow::Result<()> {
let out: Box<dyn Write> = match self.output.as_path() {
p if p.to_string_lossy() == "-" => Box::new(std::io::stdout().lock()),
p => Box::new(std::fs::File::create(p)?),
};
self.write(config, out)?;
Ok(())
}
}