use std::{
error::Error,
fmt::Display,
path::{Path, PathBuf},
};
use crate::{BuildVersioningError, ChangeType, PackageName, Versioning};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Change {
pub unique_id: UniqueId,
pub versioning: Versioning,
pub summary: String,
}
impl Change {
pub fn write_to_directory<T: AsRef<Path>>(&self, path: T) -> std::io::Result<PathBuf> {
let output_path = path.as_ref().join(self.unique_id.to_file_name());
std::fs::write(&output_path, self.to_string())?;
Ok(output_path)
}
pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, LoadingError> {
let path = path.as_ref();
let file_name = path
.file_name()
.ok_or(LoadingError::InvalidFileName)?
.to_string_lossy();
let contents = std::fs::read_to_string(path)?;
Self::from_file_name_and_content(file_name.as_ref(), &contents)
}
pub fn from_file_name_and_content(
file_name: &str,
content: &str,
) -> Result<Self, LoadingError> {
let unique_id = file_name
.strip_suffix(".md")
.ok_or(LoadingError::InvalidFileName)
.map(UniqueId::exact)?;
Self::from_str(unique_id, content).map_err(LoadingError::from)
}
fn from_str(unique_id: UniqueId, content: &str) -> Result<Self, ParsingError> {
let mut lines = content.lines();
let first_line = lines.next().ok_or(ParsingError::MissingFrontMatter)?;
if first_line.trim() != "---" {
return Err(ParsingError::MissingFrontMatter);
}
let versioning_iter = lines
.clone()
.take_while(|line| line.trim() != "---")
.map(|line| {
let parts = line
.split_once(':')
.ok_or(ParsingError::InvalidFrontMatter)?;
let package_name = PackageName::from(parts.0.trim());
let change_type = ChangeType::from(parts.1.trim());
Ok((package_name, change_type))
})
.collect::<Result<Vec<(String, ChangeType)>, ParsingError>>()?;
let versioning = Versioning::try_from_iter(versioning_iter)?;
let mut lines = lines.skip(versioning.len());
let end_front_matter = lines.next().ok_or(ParsingError::InvalidFrontMatter)?;
if end_front_matter.trim() != "---" {
return Err(ParsingError::InvalidFrontMatter);
}
let summary = lines
.skip_while(|line| line.trim().is_empty())
.collect::<Vec<_>>()
.join("\n");
Ok(Self {
unique_id,
versioning,
summary,
})
}
}
#[cfg(test)]
mod test_change {
use super::*;
#[test]
fn it_can_contain_spaces_in_package_names() {
let change = Change::from_str(
UniqueId::normalize("a change"),
r"---
package name: patch
package name 2: minor
---
This is a summary
",
)
.unwrap();
assert_eq!(
change.versioning,
Versioning::from_iter(vec![
(PackageName::from("package name"), ChangeType::Patch),
(PackageName::from("package name 2"), ChangeType::Minor),
])
);
}
#[test]
fn it_can_contain_spaces_in_change_types() {
let change = Change::from_str(
UniqueId::normalize("a change"),
r"---
package: custom change type
package name 2: something custom
---
This is a summary
",
)
.unwrap();
assert_eq!(
change.versioning,
Versioning::from_iter(vec![
(
PackageName::from("package"),
ChangeType::Custom("custom change type".into())
),
(
PackageName::from("package name 2"),
ChangeType::Custom("something custom".into())
),
])
);
}
#[test]
fn it_can_have_an_empty_summary() {
let change = Change::from_str(
UniqueId::normalize("a change"),
r"---
package: patch
---",
)
.unwrap();
assert_eq!(change.summary, "");
}
}
impl Display for Change {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "---")?;
for (package_name, change_type) in self.versioning.iter() {
writeln!(f, "{package_name}: {change_type}")?;
}
writeln!(f, "---")?;
writeln!(f)?;
writeln!(f, "{}", self.summary)
}
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct UniqueId(String);
impl UniqueId {
#[must_use]
pub fn to_file_name(&self) -> String {
format!("{self}.md")
}
#[must_use]
pub fn exact<T: AsRef<str>>(value: T) -> Self {
Self(value.as_ref().to_string())
}
#[must_use]
pub fn normalize<T: AsRef<str>>(value: T) -> Self {
let mut previous_was_underscore = false;
Self(
value
.as_ref()
.chars()
.filter_map(|c| match (c, previous_was_underscore) {
(c, _) if c.is_ascii_alphanumeric() => {
previous_was_underscore = false;
Some(c.to_ascii_lowercase())
}
(' ' | '_' | '-', false) => {
previous_was_underscore = true;
Some('_')
}
_ => None,
})
.collect(),
)
}
}
impl Display for UniqueId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod test_unique_id_normalize {
use super::UniqueId;
#[test]
fn it_handles_special_characters() {
assert_eq!(
UniqueId::normalize("`[i carry your_heart with-me(i carry it in]`").to_string(),
"i_carry_your_heart_with_mei_carry_it_in"
);
}
#[test]
fn it_handles_capitalization() {
assert_eq!(
UniqueId::normalize("This is a Title").to_string(),
"this_is_a_title"
);
}
#[test]
fn it_doesnt_duplicate_underscores() {
assert_eq!(
UniqueId::normalize("Something ______ else").to_string(),
"something_else"
);
}
}
#[derive(Debug)]
pub enum ParsingError {
MissingFrontMatter,
InvalidFrontMatter,
InvalidVersioning(BuildVersioningError),
}
impl From<BuildVersioningError> for ParsingError {
fn from(err: BuildVersioningError) -> Self {
ParsingError::InvalidVersioning(err)
}
}
impl Display for ParsingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParsingError::MissingFrontMatter => write!(f, "missing front matter"),
ParsingError::InvalidFrontMatter => write!(f, "invalid front matter"),
ParsingError::InvalidVersioning(err) => {
write!(f, "invalid front matter: {err}")
}
}
}
}
impl Error for ParsingError {}
#[derive(Debug)]
pub enum LoadingError {
InvalidFileName,
Io(std::io::Error),
Parsing(ParsingError),
}
impl From<std::io::Error> for LoadingError {
fn from(err: std::io::Error) -> Self {
LoadingError::Io(err)
}
}
impl From<ParsingError> for LoadingError {
fn from(err: ParsingError) -> Self {
LoadingError::Parsing(err)
}
}
impl Display for LoadingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadingError::InvalidFileName => write!(f, "invalid file name"),
LoadingError::Io(err) => Display::fmt(err, f),
LoadingError::Parsing(err) => Display::fmt(err, f),
}
}
}
impl Error for LoadingError {}