Skip to main content

ephemeris_release/
conventional.rs

1//! Commit messages read as Conventional Commits.
2
3use crate::commit_subject::CommitSubject;
4use crate::version::Bump;
5
6/// The change a Conventional Commit type stands for.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub(crate) enum CommitType {
9    /// `feat`.
10    Feature,
11    /// `fix`.
12    Fix,
13    /// Any other type, such as `docs` or `chore`.
14    Other,
15}
16
17/// One commit since the baseline of a unit.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub(crate) struct Commit {
20    /// The type, or `None` when the subject is not a Conventional Commit.
21    pub(crate) kind: Option<CommitType>,
22    /// The description, or the whole subject when it does not parse.
23    pub(crate) description: String,
24    /// Whether the commit marks a breaking change.
25    pub(crate) breaking: bool,
26}
27
28/// A subject line that is not a Conventional Commit.
29#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
30enum SubjectError {
31    /// The type is empty or holds whitespace or parentheses.
32    #[error("the subject does not start with a type")]
33    InvalidType,
34    /// The scope has no closing parenthesis.
35    #[error("the scope is not closed")]
36    UnclosedScope,
37    /// The scope is empty or holds whitespace or parentheses.
38    #[error("the scope is not valid")]
39    InvalidScope,
40    /// The type is not followed by a colon and a space.
41    #[error("the type is not followed by a colon and a space")]
42    MissingSeparator,
43    /// Nothing follows the separator.
44    #[error("the description is empty")]
45    EmptyDescription,
46}
47
48/// The parts of a Conventional Commit subject line that a release uses.
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50struct Subject<'a> {
51    kind: CommitType,
52    breaking: bool,
53    description: &'a str,
54}
55
56impl Commit {
57    /// Read commit messages, skipping the ones whose subject matches the
58    /// proposal `template`.
59    pub(crate) fn from_messages<'a>(
60        messages: impl IntoIterator<Item = &'a str>,
61        template: &CommitSubject,
62    ) -> Vec<Self> {
63        messages
64            .into_iter()
65            .filter(|message| !template.matches(Self::subject(message)))
66            .map(Self::parse)
67            .collect()
68    }
69
70    /// Read one commit message. A body line starting with `BREAKING CHANGE:`
71    /// or `BREAKING-CHANGE:` marks a breaking change too.
72    pub(crate) fn parse(message: &str) -> Self {
73        let subject = Self::subject(message);
74        let footer = message.lines().skip(1).any(|line| {
75            line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
76        });
77        Subject::parse(subject).map_or_else(
78            |_| Self {
79                kind: None,
80                description: subject.to_owned(),
81                breaking: footer,
82            },
83            |parsed| Self {
84                kind: Some(parsed.kind),
85                description: parsed.description.to_owned(),
86                breaking: parsed.breaking || footer,
87            },
88        )
89    }
90
91    /// The first line of a commit message.
92    fn subject(message: &str) -> &str {
93        message.lines().next().unwrap_or_default().trim()
94    }
95
96    /// Whether this commit is a feature, a fix or a breaking change.
97    pub(crate) const fn is_release_worthy(&self) -> bool {
98        self.breaking || matches!(self.kind, Some(CommitType::Feature | CommitType::Fix))
99    }
100
101    /// The bump this commit asks for.
102    pub(crate) const fn bump(&self) -> Bump {
103        match (self.breaking, self.kind) {
104            (true, _) => Bump::Major,
105            (false, Some(CommitType::Feature)) => Bump::Minor,
106            (false, Some(CommitType::Fix | CommitType::Other) | None) => Bump::Patch,
107        }
108    }
109}
110
111impl<'a> Subject<'a> {
112    /// Split `type(scope)!: description`, where the scope and `!` are
113    /// optional.
114    fn parse(subject: &'a str) -> Result<Self, SubjectError> {
115        let invalid = |part: &str| {
116            part.is_empty() || part.contains(|c: char| c.is_whitespace() || "()".contains(c))
117        };
118        let end = subject
119            .find(['(', '!', ':'])
120            .ok_or(SubjectError::MissingSeparator)?;
121        let (kind, mut rest) = subject.split_at(end);
122        if invalid(kind) {
123            return Err(SubjectError::InvalidType);
124        }
125        if let Some(scoped) = rest.strip_prefix('(') {
126            let (scope, after) = scoped.split_once(')').ok_or(SubjectError::UnclosedScope)?;
127            if invalid(scope) {
128                return Err(SubjectError::InvalidScope);
129            }
130            rest = after;
131        }
132        let (breaking, rest) = rest
133            .strip_prefix('!')
134            .map_or((false, rest), |rest| (true, rest));
135        let description = rest
136            .strip_prefix(": ")
137            .ok_or(SubjectError::MissingSeparator)?
138            .trim();
139        if description.is_empty() {
140            return Err(SubjectError::EmptyDescription);
141        }
142        let kind = if kind.eq_ignore_ascii_case("feat") {
143            CommitType::Feature
144        } else if kind.eq_ignore_ascii_case("fix") {
145            CommitType::Fix
146        } else {
147            CommitType::Other
148        };
149        Ok(Self {
150            kind,
151            breaking,
152            description,
153        })
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{Commit, CommitType, Subject, SubjectError};
160    use crate::commit_subject::CommitSubject;
161    use crate::version::Bump;
162
163    fn commit(kind: Option<CommitType>, description: &str, breaking: bool) -> Commit {
164        Commit {
165            kind,
166            description: description.to_owned(),
167            breaking,
168        }
169    }
170
171    #[test]
172    fn a_plain_type_is_read() {
173        assert_eq!(
174            Commit::parse("feat: add the login\n"),
175            commit(Some(CommitType::Feature), "add the login", false)
176        );
177        assert_eq!(
178            Commit::parse("Fix: handle an empty input"),
179            commit(Some(CommitType::Fix), "handle an empty input", false)
180        );
181        assert_eq!(
182            Commit::parse("docs: describe the login"),
183            commit(Some(CommitType::Other), "describe the login", false)
184        );
185    }
186
187    #[test]
188    fn the_scope_is_dropped() {
189        assert_eq!(
190            Commit::parse("fix(cli): reject an empty name"),
191            commit(Some(CommitType::Fix), "reject an empty name", false)
192        );
193        assert_eq!(Subject::parse("fix(): x"), Err(SubjectError::InvalidScope));
194        assert_eq!(
195            Subject::parse("fix(cli: x"),
196            Err(SubjectError::UnclosedScope)
197        );
198    }
199
200    #[test]
201    fn the_marker_breaks_with_and_without_a_scope() {
202        assert_eq!(
203            Commit::parse("refactor!: drop the old flag"),
204            commit(Some(CommitType::Other), "drop the old flag", true)
205        );
206        assert_eq!(
207            Commit::parse("feat(api)!: rename the client"),
208            commit(Some(CommitType::Feature), "rename the client", true)
209        );
210    }
211
212    #[test]
213    fn a_footer_breaks() {
214        for footer in ["BREAKING CHANGE: the flag is gone", "BREAKING-CHANGE: gone"] {
215            assert_eq!(
216                Commit::parse(&format!("fix: drop the flag\n\nbody text\n\n{footer}\n")),
217                commit(Some(CommitType::Fix), "drop the flag", true)
218            );
219        }
220        assert!(!Commit::parse("fix: x\n\nno BREAKING CHANGE: here\n").breaking);
221        assert!(Commit::parse("Drop the flag\n\nBREAKING CHANGE: gone\n").breaking);
222    }
223
224    #[test]
225    fn a_non_conventional_subject_keeps_the_whole_subject() {
226        for subject in [
227            "Change app",
228            "fix:missing space",
229            "two words: x",
230            "feat: ",
231            "feat",
232        ] {
233            assert_eq!(
234                Commit::parse(subject),
235                commit(None, subject.trim(), false),
236                "{subject:?}"
237            );
238        }
239        assert_eq!(
240            Subject::parse("two words: x"),
241            Err(SubjectError::InvalidType)
242        );
243        assert_eq!(Subject::parse("fix:x"), Err(SubjectError::MissingSeparator));
244        assert_eq!(Subject::parse("fix: "), Err(SubjectError::EmptyDescription));
245    }
246
247    #[test]
248    fn the_release_commit_is_excluded() {
249        let commits = Commit::from_messages(
250            [
251                "fix: first\n",
252                "chore(release): app 1.0.0\n",
253                "chore(release): update proposal\n",
254                "feat: second\n",
255            ],
256            &CommitSubject::try_from("chore(release): {unit} {version}").unwrap(),
257        );
258        assert_eq!(
259            commits
260                .iter()
261                .map(|commit| commit.description.as_str())
262                .collect::<Vec<_>>(),
263            ["first", "second"]
264        );
265    }
266
267    #[test]
268    fn a_break_bumps_major_and_a_feature_minor() {
269        assert_eq!(Commit::parse("docs!: x").bump(), Bump::Major);
270        assert_eq!(Commit::parse("feat: x").bump(), Bump::Minor);
271        assert_eq!(Commit::parse("fix: x").bump(), Bump::Patch);
272        assert_eq!(Commit::parse("Change x").bump(), Bump::Patch);
273    }
274}