use chrono::{offset::Local, Datelike};
use dynfmt::{Format, SimpleCurlyFormat};
use std::{
collections::HashMap,
fs::File,
io::{prelude::*, BufReader, Cursor},
path::PathBuf,
};
use thiserror::Error as ThisError;
use crate::{
app::AppSession,
errors::{Error, Result},
project::Project,
repository::{ChangeList, CommitId, PathMatcher, RcProjectInfo, RepoPathBuf, Repository},
};
pub trait Changelog: std::fmt::Debug {
fn draft_release_update(
&self,
proj: &Project,
sess: &AppSession,
changes: &[CommitId],
prev_release_commit: Option<CommitId>,
) -> Result<()>;
fn replace_changelog(
&self,
proj: &Project,
sess: &AppSession,
changes: &mut ChangeList,
prev_release_commit: CommitId,
) -> Result<()>;
fn create_path_matcher(&self, proj: &Project) -> Result<PathMatcher>;
fn scan_rc_info(&self, proj: &Project, repo: &Repository) -> Result<RcProjectInfo>;
fn finalize_changelog(
&self,
proj: &Project,
repo: &Repository,
changes: &mut ChangeList,
) -> Result<()>;
fn scan_changelog(&self, proj: &Project, repo: &Repository, cid: &CommitId) -> Result<String>;
}
pub fn default() -> Box<dyn Changelog> {
Box::new(MarkdownChangelog::default())
}
#[derive(Debug, ThisError)]
pub struct InvalidChangelogFormatError(pub PathBuf);
impl std::fmt::Display for InvalidChangelogFormatError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"changelog file `{}` does not obey the expected formatting",
self.0.display()
)
}
}
#[derive(Debug)]
pub struct MarkdownChangelog {
basename: String,
release_header_format: String,
stage_header_format: String,
footer_format: String,
}
impl Default for MarkdownChangelog {
fn default() -> Self {
MarkdownChangelog {
basename: "CHANGELOG.md".to_owned(),
release_header_format: "# {project_slug} {version} ({yyyy_mm_dd})\n".to_owned(),
stage_header_format: "# rc: {bump_spec}\n".to_owned(),
footer_format: "".to_owned(),
}
}
}
impl MarkdownChangelog {
fn changelog_repopath(&self, proj: &Project) -> RepoPathBuf {
let mut pfx = proj.prefix().to_owned();
pfx.push(&self.basename);
pfx
}
fn changelog_path(&self, proj: &Project, repo: &Repository) -> PathBuf {
repo.resolve_workdir(&self.changelog_repopath(proj))
}
fn replace_changelog_impl(
&self,
proj: &Project,
sess: &AppSession,
prev_release_commit: Option<CommitId>,
in_changes: Option<&[CommitId]>,
out_changes: Option<&mut ChangeList>,
) -> Result<()> {
let changelog_repopath = self.changelog_repopath(proj);
let prev_log: Vec<u8> = prev_release_commit
.map(|prc| sess.repo.get_file_at_commit(&prc, &changelog_repopath))
.transpose()?
.flatten()
.unwrap_or_else(Vec::new);
let changelog_path = self.changelog_path(proj, &sess.repo);
let new_af = atomicwrites::AtomicFile::new(
&changelog_path,
atomicwrites::OverwriteBehavior::AllowOverwrite,
);
let r = new_af.write(|new_f| {
if let Some(commits) = in_changes {
let mut headfoot_args = HashMap::new();
headfoot_args.insert("bump_spec", "micro bump");
let header = SimpleCurlyFormat
.format(&self.stage_header_format, &headfoot_args)
.map_err(|e| Error::msg(e.to_string()))?;
writeln!(new_f, "{}", header)?;
const WRAP_WIDTH: usize = 78;
for cid in commits {
let message = sess.repo.get_commit_summary(*cid)?;
let mut prefix = "- ";
for line in textwrap::wrap_iter(&message, WRAP_WIDTH) {
writeln!(new_f, "{}{}", prefix, line)?;
prefix = " ";
}
}
let footer = SimpleCurlyFormat
.format(&self.footer_format, &headfoot_args)
.map_err(|e| Error::msg(e.to_string()))?;
writeln!(new_f, "{}", footer)?;
}
new_f.write_all(&prev_log[..])?;
Ok(())
});
if let Some(chlist) = out_changes {
chlist.add_path(&self.changelog_repopath(proj));
}
match r {
Err(atomicwrites::Error::Internal(e)) => Err(e.into()),
Err(atomicwrites::Error::User(e)) => Err(e),
Ok(()) => Ok(()),
}
}
}
impl Changelog for MarkdownChangelog {
fn draft_release_update(
&self,
proj: &Project,
sess: &AppSession,
changes: &[CommitId],
prev_release_commit: Option<CommitId>,
) -> Result<()> {
self.replace_changelog_impl(proj, sess, prev_release_commit, Some(changes), None)
}
fn replace_changelog(
&self,
proj: &Project,
sess: &AppSession,
changes: &mut ChangeList,
prev_release_commit: CommitId,
) -> Result<()> {
self.replace_changelog_impl(proj, sess, Some(prev_release_commit), None, Some(changes))
}
fn create_path_matcher(&self, proj: &Project) -> Result<PathMatcher> {
Ok(PathMatcher::new_include(self.changelog_repopath(proj)))
}
fn scan_rc_info(&self, proj: &Project, repo: &Repository) -> Result<RcProjectInfo> {
let changelog_path = self.changelog_path(proj, repo);
let f = File::open(&changelog_path)?;
let reader = BufReader::new(f);
let mut bump_spec = None;
for maybe_line in reader.lines() {
let line = maybe_line?;
if line.trim().is_empty() {
continue;
}
if let Some(spec_text) = line.strip_prefix("# rc:") {
let spec = spec_text.trim();
bump_spec = Some(spec.to_owned());
break;
}
return Err(InvalidChangelogFormatError(changelog_path).into());
}
let bump_spec = bump_spec.ok_or(InvalidChangelogFormatError(changelog_path))?;
let _check_scheme = proj.version.parse_bump_scheme(&bump_spec)?;
Ok(RcProjectInfo {
qnames: proj.qualified_names().clone(),
bump_spec,
})
}
fn finalize_changelog(
&self,
proj: &Project,
repo: &Repository,
changes: &mut ChangeList,
) -> Result<()> {
let mut header_args = HashMap::new();
header_args.insert("project_slug", proj.user_facing_name.to_owned());
header_args.insert("version", proj.version.to_string());
let now = Local::now();
header_args.insert(
"yyyy_mm_dd",
format!("{:04}-{:02}-{:02}", now.year(), now.month(), now.day()),
);
let changelog_path = self.changelog_path(proj, repo);
let cur_f = File::open(&changelog_path)?;
let cur_reader = BufReader::new(cur_f);
let new_af = atomicwrites::AtomicFile::new(
&changelog_path,
atomicwrites::OverwriteBehavior::AllowOverwrite,
);
let r = new_af.write(|new_f| {
#[allow(clippy::enum_variant_names)]
enum State {
BeforeHeader,
BlanksAfterHeader,
AfterHeader,
}
let mut state = State::BeforeHeader;
for maybe_line in cur_reader.lines() {
let line = maybe_line?;
match state {
State::BeforeHeader => {
if line.trim().is_empty() {
continue;
}
if !line.starts_with("# rc:") {
return Err(InvalidChangelogFormatError(changelog_path).into());
}
state = State::BlanksAfterHeader;
let header = SimpleCurlyFormat
.format(&self.release_header_format, &header_args)
.map_err(|e| Error::msg(e.to_string()))?;
writeln!(new_f, "{}", header)?;
}
State::BlanksAfterHeader => {
if !line.trim().is_empty() {
state = State::AfterHeader;
writeln!(new_f, "{}", line)?;
}
}
State::AfterHeader => {
writeln!(new_f, "{}", line)?;
}
}
}
Ok(())
});
changes.add_path(&self.changelog_repopath(proj));
match r {
Err(atomicwrites::Error::Internal(e)) => Err(e.into()),
Err(atomicwrites::Error::User(e)) => Err(e),
Ok(()) => Ok(()),
}
}
fn scan_changelog(&self, proj: &Project, repo: &Repository, cid: &CommitId) -> Result<String> {
let changelog_path = self.changelog_repopath(proj);
let data = match repo.get_file_at_commit(cid, &changelog_path)? {
Some(d) => d,
None => return Ok(String::new()),
};
let reader = Cursor::new(data);
enum State {
BeforeHeader,
InChangelog,
}
let mut state = State::BeforeHeader;
let mut changelog = String::new();
for maybe_line in reader.lines() {
let line = maybe_line?;
match state {
State::BeforeHeader => {
if line.starts_with("# ") {
changelog.push_str(&line);
changelog.push('\n');
state = State::InChangelog;
}
}
State::InChangelog => {
if line.starts_with("# ") {
break;
} else {
changelog.push_str(&line);
changelog.push('\n');
}
}
}
}
Ok(changelog)
}
}