use std::fmt::{self, Display};
use markdown::ParseOptions;
use markdown::mdast::Node;
use super::{Changeset, ChangesetRef, MultilineText, ReferenceRef};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Release {
version: String,
date: Option<String>,
url: Option<String>,
description: Option<String>,
changesets: Vec<Changeset>,
references: Vec<(String, String)>,
}
impl Release {
pub fn new(version: impl Into<String>) -> Self {
Self {
version: version.into(),
date: None,
url: None,
description: None,
changesets: Vec::new(),
references: Vec::new(),
}
}
pub fn version(&self) -> &str {
&self.version
}
pub fn set_version(&mut self, version: impl Into<String>) -> &mut Self {
self.version = version.into();
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.set_version(version);
self
}
pub fn date(&self) -> Option<&str> {
self.date.as_deref()
}
pub fn set_date(&mut self, date: impl Into<String>) -> &mut Self {
self.date = Some(date.into());
self
}
pub fn with_date(mut self, date: impl Into<String>) -> Self {
self.set_date(date);
self
}
pub fn url(&self) -> Option<&str> {
self.url.as_deref()
}
pub fn set_url(&mut self, url: impl Into<String>) -> &mut Self {
self.url = Some(url.into());
self
}
pub fn with_url(mut self, url: impl Into<String>) -> Self {
self.set_url(url);
self
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
pub fn set_description(&mut self, description: impl Into<String>) -> &mut Self {
self.description = Some(description.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.set_description(description);
self
}
pub fn add_changeset(&mut self, changeset: impl Into<Changeset>) -> &mut Self {
self.changesets.push(changeset.into());
self
}
pub fn with_changeset(mut self, changeset: impl Into<Changeset>) -> Self {
self.add_changeset(changeset);
self
}
pub fn changesets(&self) -> impl Iterator<Item = &Changeset> {
self.changesets.iter()
}
pub fn add_reference(&mut self, id: impl Into<String>, url: impl Into<String>) -> &mut Self {
self.references.push((id.into(), url.into()));
self
}
pub fn with_reference(mut self, id: impl Into<String>, url: impl Into<String>) -> Self {
self.add_reference(id, url);
self
}
pub fn references(&self) -> impl Iterator<Item = (&str, &str)> {
self.references.iter().map(|(id, url)| (&**id, &**url))
}
}
impl Release {
pub(super) fn into_nodes(self) -> Vec<Node> {
let mut nodes = Vec::new();
let version = Node::LinkReference(markdown::mdast::LinkReference {
children: vec![Node::Text(markdown::mdast::Text {
value: self.version.clone(),
position: None,
})],
position: None,
reference_kind: markdown::mdast::ReferenceKind::Shortcut,
identifier: self.version,
label: None,
});
let date = self.date.map(|date| {
Node::Text(markdown::mdast::Text {
value: format!(" - {date}"),
position: None,
})
});
let heading = Node::Heading(markdown::mdast::Heading {
children: Some(version).into_iter().chain(date).collect(),
position: None,
depth: 2,
});
nodes.push(heading);
if let Some(description) = self.description {
let md = markdown::to_mdast(&description, &ParseOptions::default()).expect("markdown");
if let Node::Root(root) = md {
nodes.extend(root.children);
}
}
nodes.extend(self.changesets.into_iter().flat_map(Changeset::into_nodes));
nodes
}
}
impl Display for Release {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.date {
Some(date) => write!(f, "## [{}] - {date}", self.version)?,
None => write!(f, "## [{}]", self.version)?,
}
if let Some(description) = self.description() {
if f.alternate() {
write!(f, "\n\n{description:#}")?;
} else {
write!(f, "\n\n{description}")?;
}
}
let mut changesets = self.changesets().peekable();
if changesets.peek().is_some() {
write!(f, "\n\n")?;
while let Some(changeset) = changesets.next() {
if f.alternate() {
write!(f, "{changeset:#}")?;
} else {
write!(f, "{changeset}")?;
}
if changesets.peek().is_some() {
write!(f, "\n\n")?;
}
}
}
let mut references = self.references().peekable();
if references.peek().is_some() {
write!(f, "\n\n")?;
while let Some((id, url)) = references.next() {
write!(f, "[{id}]: {url}")?;
if references.peek().is_some() {
writeln!(f)?;
}
}
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct ReleaseRef<'a> {
version: &'a str,
date: Option<&'a str>,
nodes: &'a [Node],
}
impl<'a> ReleaseRef<'a> {
pub fn version(&self) -> &str {
self.version
}
pub fn date(&self) -> Option<&str> {
self.date
}
pub fn description(&self) -> Option<MultilineText<'_>> {
MultilineText::from_nodes(self.nodes)
}
pub fn added(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Added")
}
pub fn changed(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Changed")
}
pub fn deprecated(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Deprecated")
}
pub fn removed(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Removed")
}
pub fn fixed(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Fixed")
}
pub fn security(&self) -> Option<ChangesetRef<'a>> {
self.get_changeset("Security")
}
pub fn get_changeset(&self, label: impl AsRef<str>) -> Option<ChangesetRef<'a>> {
self.changesets()
.find(|changeset| changeset.label() == label.as_ref())
}
pub fn changesets(&self) -> impl Iterator<Item = ChangesetRef<'a>> + use<'a> {
self.get_sections().filter_map(ChangesetRef::from_nodes)
}
pub fn references(&self) -> impl Iterator<Item = ReferenceRef<'a>> + use<'a> {
self.nodes
.chunk_by(|node, _| matches!(node, Node::Definition(_)))
.last()
.into_iter()
.flat_map(|nodes| {
nodes.iter().filter_map(|node| match node {
Node::Definition(definition) => Some(ReferenceRef::from_definition(definition)),
_ => None,
})
})
}
pub fn to_owned(&self) -> Release {
Release {
version: self.version().to_owned(),
date: self.date().map(ToOwned::to_owned),
url: None,
description: self.description().map(|text| text.to_string()),
changesets: self
.changesets()
.map(|changeset| changeset.to_owned())
.collect(),
references: self
.references()
.map(|reference| reference.to_owned())
.collect(),
}
}
}
impl<'a> ReleaseRef<'a> {
pub(super) fn from_nodes(nodes: &'a [Node]) -> Option<Self> {
let Node::Heading(heading) = nodes.first()? else {
return None;
};
if heading.depth != 2 {
return None;
}
let version = heading.children.iter().find_map(|node| match node {
Node::LinkReference(link) => Some(&link.identifier),
_ => None,
})?;
let date = heading.children.iter().find_map(|node| match node {
Node::Text(text) => Some(text.value.trim().trim_start_matches('-').trim()),
_ => None,
});
Some(Self {
version,
date,
nodes: &nodes[1..],
})
}
fn get_sections(&self) -> impl Iterator<Item = &'a [Node]> + use<'a> {
self.nodes
.chunk_by(|_, node| !matches!(node, Node::Heading(heading) if heading.depth == 3))
}
}
impl Display for ReleaseRef<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.date {
Some(date) => write!(f, "## [{}] - {date}", self.version)?,
None => write!(f, "## [{}]", self.version)?,
}
if let Some(description) = self.description() {
if f.alternate() {
write!(f, "\n\n{description:#}")?;
} else {
write!(f, "\n\n{description}")?;
}
}
let mut changesets = self.changesets().peekable();
if changesets.peek().is_some() {
write!(f, "\n\n")?;
while let Some(changeset) = changesets.next() {
if f.alternate() {
write!(f, "{changeset:#}")?;
} else {
write!(f, "{changeset}")?;
}
if changesets.peek().is_some() {
write!(f, "\n\n")?;
}
}
}
let mut references = self.references().peekable();
if references.peek().is_some() {
write!(f, "\n\n")?;
while let Some(reference) = references.next() {
if f.alternate() {
write!(f, "{reference:#}")?;
} else {
write!(f, "{reference}")?;
}
if references.peek().is_some() {
writeln!(f)?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use pretty_assertions::assert_eq;
use crate::changelog::{Change, Changelog, Changeset};
use super::Release;
#[test]
fn test_release() {
let release = Release::new("0.1.0")
.with_date("2024-01-01")
.with_changeset(
Changeset::fixed()
.with_description("Fixed some things.")
.with_change(
Change::new("Fixed `one`")
.with_url("#1", "https://github.com/ploys/example/pull/1"),
)
.with_change(
Change::new("Fixed `two`")
.with_url("#2", "https://github.com/ploys/example/pull/2"),
),
)
.with_reference(
"0.1.0",
"https://github.com/ploys/example/releases/tag/0.1.0",
);
let output = indoc! {"
## [0.1.0] - 2024-01-01
### Fixed
Fixed some things.
- Fixed `one` ([#1](https://github.com/ploys/example/pull/1))
- Fixed `two` ([#2](https://github.com/ploys/example/pull/2))
[0.1.0]: https://github.com/ploys/example/releases/tag/0.1.0\
"};
assert_eq!(release.version(), "0.1.0");
assert_eq!(release.date(), Some("2024-01-01"));
assert_eq!(release.changesets().count(), 1);
assert_eq!(release.references().count(), 1);
assert_eq!(release.to_string(), output);
}
#[test]
fn test_release_to_owned() {
let document = indoc! {"
## [0.1.0] - 2024-01-01
### Fixed
Fixed some things.
- Fixed `one` ([#1](https://github.com/ploys/example/pull/1))
- Fixed `two` ok ([#2](https://github.com/ploys/example/pull/2))
- Fixed three ([#3](https://github.com/ploys/example/pull/3))
[0.1.0]: https://github.com/ploys/example/releases/tag/0.1.0\
"};
let changelog = document.parse::<Changelog>().unwrap();
let release_ref = changelog.get_release("0.1.0").unwrap();
let release = Release::new("0.1.0")
.with_date("2024-01-01")
.with_changeset(
Changeset::fixed()
.with_description("Fixed some things.")
.with_change(
Change::new("Fixed `one`")
.with_url("#1", "https://github.com/ploys/example/pull/1"),
)
.with_change(
Change::new("Fixed `two` ok")
.with_url("#2", "https://github.com/ploys/example/pull/2"),
)
.with_change(
Change::new("Fixed three")
.with_url("#3", "https://github.com/ploys/example/pull/3"),
),
)
.with_reference(
"0.1.0",
"https://github.com/ploys/example/releases/tag/0.1.0",
);
assert_eq!(release_ref.to_owned(), release);
}
}