1use crate::commit_subject::CommitSubject;
4use crate::version::Bump;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub(crate) enum CommitType {
9 Feature,
11 Fix,
13 Other,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub(crate) struct Commit {
20 pub(crate) kind: Option<CommitType>,
22 pub(crate) description: String,
24 pub(crate) breaking: bool,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
30enum SubjectError {
31 #[error("the subject does not start with a type")]
33 InvalidType,
34 #[error("the scope is not closed")]
36 UnclosedScope,
37 #[error("the scope is not valid")]
39 InvalidScope,
40 #[error("the type is not followed by a colon and a space")]
42 MissingSeparator,
43 #[error("the description is empty")]
45 EmptyDescription,
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50struct Subject<'a> {
51 kind: CommitType,
52 breaking: bool,
53 description: &'a str,
54}
55
56impl Commit {
57 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 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 fn subject(message: &str) -> &str {
93 message.lines().next().unwrap_or_default().trim()
94 }
95
96 pub(crate) const fn is_release_worthy(&self) -> bool {
98 self.breaking || matches!(self.kind, Some(CommitType::Feature | CommitType::Fix))
99 }
100
101 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 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}