changesets/
change.rs

1use std::{
2    error::Error,
3    fmt::Display,
4    path::{Path, PathBuf},
5};
6
7use crate::{BuildVersioningError, ChangeType, PackageName, Versioning};
8
9/// Represents a single [change](https://github.com/knope-dev/changesets#terminology) which is
10/// applicable to any number of packages.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Change {
13    /// Something to uniquely identify a change.
14    ///
15    /// This is the name of the file (without the `.md` extension) which defines this changeset.
16    pub unique_id: UniqueId,
17    /// Describes how a changeset affects the relevant packages.
18    pub versioning: Versioning,
19    /// The details of the change which will be written to a Changelog file
20    pub summary: String,
21}
22
23impl Change {
24    /// Create a markdown file in the provided directory with the contents of this [`Change`].
25    ///
26    /// The name of the created file will be the [`Change::unique_id`] with the `.md` extension—
27    /// that path is returned.
28    ///
29    /// # Errors
30    ///
31    /// If the file cannot be written, an [`std::io::Error`] is returned. This may happen if the
32    /// directory does not exist.
33    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    /// Load a [`Change`] from a Markdown file.
40    ///
41    /// # Errors
42    ///
43    /// - If the file can't be read
44    /// - If the file doesn't have a valid name (it doesn't end in `.md`)
45    /// - If the file doesn't have a valid front matter
46    /// - If the file doesn't have valid versioning info in the front matter
47    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    /// Given the name of a file and its content, create a [`Change`].
58    ///
59    /// # Errors
60    ///
61    /// - If the file doesn't have a valid name (it doesn't end in `.md`)
62    /// - If the file doesn't have a valid front matter
63    /// - If the file doesn't have valid versioning info in the front matter
64    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/// The unique ID of a [`Change`], used to set the file name of the Markdown file.
189#[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    /// Creates a new [`UniqueId`] from a string without altering the value at all. For working on
200    /// with existing paths.
201    /// Use [`Self::normalize`] when creating new files.
202    pub fn exact<T: AsRef<str>>(value: T) -> Self {
203        Self(value.as_ref().to_string())
204    }
205
206    #[must_use]
207    /// Converts an arbitrary string into only lower case letters and underscores, for creating
208    /// file names from arbitrary strings.
209    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 {}