use std::{fmt::Display, str::FromStr};
use itertools::Itertools;
use relative_path::RelativePathBuf;
use thiserror::Error;
use time::{macros::format_description, Date};
use crate::{package, release_notes::Release, semver::Version};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Changelog {
pub path: RelativePathBuf,
pub content: String,
release_header_level: HeaderLevel,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HeaderLevel {
H1,
H2,
}
impl HeaderLevel {
const fn as_str(self) -> &'static str {
match self {
Self::H1 => "#",
Self::H2 => "##",
}
}
}
impl Display for HeaderLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Changelog {
#[must_use]
pub fn new(path: RelativePathBuf, content: String) -> Self {
let release_header_level = content
.lines()
.filter(|line| line.starts_with('#'))
.nth(1)
.and_then(|header| {
if header.starts_with("##") {
Some(HeaderLevel::H2)
} else if header.starts_with('#') {
Some(HeaderLevel::H1)
} else {
None
}
})
.unwrap_or(HeaderLevel::H2);
Changelog {
path,
content,
release_header_level,
}
}
#[must_use]
pub fn get_release(&self, version: &Version, package_name: &package::Name) -> Option<Release> {
let expected_header_start = format!(
"{release_header_level} {version}",
release_header_level = self.release_header_level
);
let mut lines = self.content.lines();
let (title, version) = loop {
let line = lines.next()?;
if !line.starts_with(&expected_header_start) {
continue;
}
let Ok((header_level, title_version, _)) = parse_title(line) else {
continue;
};
if header_level == self.release_header_level && *version == title_version {
break (
line.trim_start_matches('#').trim().to_string(),
title_version,
);
}
};
let notes = lines
.take_while(|line| {
!line.starts_with(&format!(
"{release_header_level} ",
release_header_level = self.release_header_level
))
})
.map(|line| match self.release_header_level {
HeaderLevel::H1 => line,
HeaderLevel::H2 => reduce_header_level(line),
})
.join("\n");
(!notes.is_empty()).then_some(Release {
title,
version,
notes,
package_name: package_name.clone(),
})
}
#[must_use]
pub fn with_release(&mut self, release: &Release) -> String {
let mut not_written = true;
let new_changes = format!(
"{header_level} {title}\n\n{body}",
header_level = self.release_header_level,
title = release.title,
body = release
.notes
.lines()
.map(|line| {
if line.starts_with('#') && self.release_header_level == HeaderLevel::H2 {
format!("#{line}")
} else {
line.to_string()
}
})
.join("\n")
);
let mut new_content = String::with_capacity(self.content.len() + new_changes.len());
for line in self.content.lines() {
if not_written && parse_title(line).is_ok() {
new_content.push_str(&new_changes);
new_content.push_str("\n\n");
not_written = false;
}
new_content.push_str(line);
new_content.push('\n');
}
if not_written {
new_content.push_str(&new_changes);
}
if (self.content.ends_with('\n') || self.content.is_empty()) && !new_content.ends_with('\n')
{
new_content.push('\n');
}
self.content = new_content;
new_changes
}
}
fn parse_title(title: &str) -> Result<(HeaderLevel, Version, Option<Date>), ParseError> {
let mut parts = title.split_ascii_whitespace();
let header_level = match parts.next() {
Some("##") => HeaderLevel::H2,
Some("#") => HeaderLevel::H1,
_ => return Err(ParseError::HeaderLevel),
};
let version = parts.next().ok_or(ParseError::MissingVersion)?;
let version = Version::from_str(version).map_err(|_| ParseError::MissingVersion)?;
let mut date = None;
for part in parts {
let part = part.trim_start_matches('(').trim_end_matches(')');
date = Date::parse(part, format_description!("[year]-[month]-[day]")).ok();
if date.is_some() {
break;
}
}
Ok((header_level, version, date))
}
fn reduce_header_level(line: &str) -> &str {
if line.starts_with("##") {
#[allow(clippy::indexing_slicing)] &line[1..] } else {
line
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test_parse_title {
use time::macros::date;
use super::*;
#[test]
fn no_date() {
let title = "## 0.1.2";
let (header_level, version, date) = parse_title(title).unwrap();
assert_eq!(header_level, HeaderLevel::H2);
assert_eq!(version, Version::new(0, 1, 2, None));
assert!(date.is_none());
}
#[test]
fn with_date() {
let title = "## 0.1.2 (2023-05-02)";
let (header_level, version, date) = parse_title(title).unwrap();
assert_eq!(header_level, HeaderLevel::H2);
assert_eq!(version, Version::new(0, 1, 2, None));
assert_eq!(date, Some(date!(2023 - 05 - 02)));
}
#[test]
fn no_version() {
let title = "## 2023-05-02";
let result = parse_title(title);
assert!(result.is_err());
}
#[test]
fn bad_version() {
let title = "## sad";
let result = parse_title(title);
assert!(result.is_err());
}
#[test]
fn h1() {
let title = "# 0.1.2 (2023-05-02)";
let (header_level, version, date) = parse_title(title).unwrap();
assert_eq!(header_level, HeaderLevel::H1);
assert_eq!(version, Version::new(0, 1, 2, None));
assert_eq!(date, Some(date!(2023 - 05 - 02)));
}
}
#[derive(Clone, Debug, Eq, PartialEq, Error)]
#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
pub enum ParseError {
#[error("Missing version")]
#[cfg_attr(
feature = "miette",
diagnostic(
code = "changelog::missing_version",
help = "The expected changelog format is very particular, a release title must start with the
semantic version immediately after the header level. For example: `## 0.1.0 - 2020-12-25"
)
)]
MissingVersion,
#[error("Bad header level")]
#[cfg_attr(
feature = "miette",
diagnostic(
code = "changelog::header_level",
help = "The expected changelog format is very particular, a release title be header level 1
(#) or 2 (##). For example: `## 0.1.0 - 2020-12-25"
)
)]
HeaderLevel,
}