1use std::{
2 error::Error,
3 fmt::Display,
4 path::{Path, PathBuf},
5};
6
7use crate::{BuildVersioningError, ChangeType, PackageName, Versioning};
8
9#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Change {
13 pub unique_id: UniqueId,
17 pub versioning: Versioning,
19 pub summary: String,
21}
22
23impl Change {
24 pub fn write_to_directory<T: AsRef<Path>>(&self, path: T) -> std::io::Result<PathBuf> {
34 let output_path = path.as_ref().join(self.unique_id.to_file_name());
35 std::fs::write(&output_path, self.to_string())?;
36 Ok(output_path)
37 }
38
39 pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, LoadingError> {
48 let path = path.as_ref();
49 let file_name = path
50 .file_name()
51 .ok_or(LoadingError::InvalidFileName)?
52 .to_string_lossy();
53 let contents = std::fs::read_to_string(path)?;
54 Self::from_file_name_and_content(file_name.as_ref(), &contents)
55 }
56
57 pub fn from_file_name_and_content(
65 file_name: &str,
66 content: &str,
67 ) -> Result<Self, LoadingError> {
68 let unique_id = file_name
69 .strip_suffix(".md")
70 .ok_or(LoadingError::InvalidFileName)
71 .map(UniqueId::exact)?;
72 Self::from_str(unique_id, content).map_err(LoadingError::from)
73 }
74
75 fn from_str(unique_id: UniqueId, content: &str) -> Result<Self, ParsingError> {
76 let mut lines = content.lines();
77 let first_line = lines.next().ok_or(ParsingError::MissingFrontMatter)?;
78 if first_line.trim() != "---" {
79 return Err(ParsingError::MissingFrontMatter);
80 }
81 let versioning_iter = lines
82 .clone()
83 .take_while(|line| line.trim() != "---")
84 .map(|line| {
85 let parts = line
86 .split_once(':')
87 .ok_or(ParsingError::InvalidFrontMatter)?;
88 let package_name = PackageName::from(parts.0.trim());
89 let change_type = ChangeType::from(parts.1.trim());
90 Ok((package_name, change_type))
91 })
92 .collect::<Result<Vec<(String, ChangeType)>, ParsingError>>()?;
93 let versioning = Versioning::try_from_iter(versioning_iter)?;
94 let mut lines = lines.skip(versioning.len());
95 let end_front_matter = lines.next().ok_or(ParsingError::InvalidFrontMatter)?;
96 if end_front_matter.trim() != "---" {
97 return Err(ParsingError::InvalidFrontMatter);
98 }
99 let summary = lines
100 .skip_while(|line| line.trim().is_empty())
101 .collect::<Vec<_>>()
102 .join("\n");
103 Ok(Self {
104 unique_id,
105 versioning,
106 summary,
107 })
108 }
109}
110
111#[cfg(test)]
112mod test_change {
113 use super::*;
114
115 #[test]
116 fn it_can_contain_spaces_in_package_names() {
117 let change = Change::from_str(
118 UniqueId::normalize("a change"),
119 r"---
120package name: patch
121package name 2: minor
122---
123This is a summary
124",
125 )
126 .unwrap();
127 assert_eq!(
128 change.versioning,
129 Versioning::from_iter(vec![
130 (PackageName::from("package name"), ChangeType::Patch),
131 (PackageName::from("package name 2"), ChangeType::Minor),
132 ])
133 );
134 }
135
136 #[test]
137 fn it_can_contain_spaces_in_change_types() {
138 let change = Change::from_str(
139 UniqueId::normalize("a change"),
140 r"---
141package: custom change type
142package name 2: something custom
143---
144This is a summary
145",
146 )
147 .unwrap();
148 assert_eq!(
149 change.versioning,
150 Versioning::from_iter(vec![
151 (
152 PackageName::from("package"),
153 ChangeType::Custom("custom change type".into())
154 ),
155 (
156 PackageName::from("package name 2"),
157 ChangeType::Custom("something custom".into())
158 ),
159 ])
160 );
161 }
162
163 #[test]
164 fn it_can_have_an_empty_summary() {
165 let change = Change::from_str(
166 UniqueId::normalize("a change"),
167 r"---
168package: patch
169---",
170 )
171 .unwrap();
172 assert_eq!(change.summary, "");
173 }
174}
175
176impl Display for Change {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 writeln!(f, "---")?;
179 for (package_name, change_type) in self.versioning.iter() {
180 writeln!(f, "{package_name}: {change_type}")?;
181 }
182 writeln!(f, "---")?;
183 writeln!(f)?;
184 writeln!(f, "{}", self.summary)
185 }
186}
187
188#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
190pub struct UniqueId(String);
191
192impl UniqueId {
193 #[must_use]
194 pub fn to_file_name(&self) -> String {
195 format!("{self}.md")
196 }
197
198 #[must_use]
199 pub fn exact<T: AsRef<str>>(value: T) -> Self {
203 Self(value.as_ref().to_string())
204 }
205
206 #[must_use]
207 pub fn normalize<T: AsRef<str>>(value: T) -> Self {
210 let mut previous_was_underscore = false;
211 Self(
212 value
213 .as_ref()
214 .chars()
215 .filter_map(|c| match (c, previous_was_underscore) {
216 (c, _) if c.is_ascii_alphanumeric() => {
217 previous_was_underscore = false;
218 Some(c.to_ascii_lowercase())
219 }
220 (' ' | '_' | '-', false) => {
221 previous_was_underscore = true;
222 Some('_')
223 }
224 _ => None,
225 })
226 .collect(),
227 )
228 }
229}
230
231impl Display for UniqueId {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 write!(f, "{}", self.0)
234 }
235}
236
237#[cfg(test)]
238mod test_unique_id_normalize {
239 use super::UniqueId;
240
241 #[test]
242 fn it_handles_special_characters() {
243 assert_eq!(
244 UniqueId::normalize("`[i carry your_heart with-me(i carry it in]`").to_string(),
245 "i_carry_your_heart_with_mei_carry_it_in"
246 );
247 }
248
249 #[test]
250 fn it_handles_capitalization() {
251 assert_eq!(
252 UniqueId::normalize("This is a Title").to_string(),
253 "this_is_a_title"
254 );
255 }
256
257 #[test]
258 fn it_doesnt_duplicate_underscores() {
259 assert_eq!(
260 UniqueId::normalize("Something ______ else").to_string(),
261 "something_else"
262 );
263 }
264}
265
266#[derive(Debug)]
267pub enum ParsingError {
268 MissingFrontMatter,
269 InvalidFrontMatter,
270 InvalidVersioning(BuildVersioningError),
271}
272
273impl From<BuildVersioningError> for ParsingError {
274 fn from(err: BuildVersioningError) -> Self {
275 ParsingError::InvalidVersioning(err)
276 }
277}
278
279impl Display for ParsingError {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 match self {
282 ParsingError::MissingFrontMatter => write!(f, "missing front matter"),
283 ParsingError::InvalidFrontMatter => write!(f, "invalid front matter"),
284 ParsingError::InvalidVersioning(err) => {
285 write!(f, "invalid front matter: {err}")
286 }
287 }
288 }
289}
290
291impl Error for ParsingError {}
292
293#[derive(Debug)]
294pub enum LoadingError {
295 InvalidFileName,
296 Io(std::io::Error),
297 Parsing(ParsingError),
298}
299
300impl From<std::io::Error> for LoadingError {
301 fn from(err: std::io::Error) -> Self {
302 LoadingError::Io(err)
303 }
304}
305
306impl From<ParsingError> for LoadingError {
307 fn from(err: ParsingError) -> Self {
308 LoadingError::Parsing(err)
309 }
310}
311
312impl Display for LoadingError {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 match self {
315 LoadingError::InvalidFileName => write!(f, "invalid file name"),
316 LoadingError::Io(err) => Display::fmt(err, f),
317 LoadingError::Parsing(err) => Display::fmt(err, f),
318 }
319 }
320}
321
322impl Error for LoadingError {}