knope_versioning/changes/
conventional_commit.rs1use git_conventional::{Commit, Footer, Type};
2use tracing::debug;
3
4use super::{Change, ChangeSource, ChangeType};
5use crate::release_notes::Sections;
6
7pub(crate) fn changes_from_commit_messages<'a, Message: AsRef<str>>(
15 commit_messages: &'a [Message],
16 scopes: Option<&'a Vec<String>>,
17 changelog_sections: &'a Sections,
18) -> impl Iterator<Item = Change> + 'a {
19 if let Some(scopes) = scopes {
20 debug!("Only checking commits with scopes: {scopes:?}");
21 }
22 commit_messages.iter().flat_map(move |message| {
23 changes_from_commit_message(message.as_ref(), scopes, changelog_sections).into_iter()
24 })
25}
26
27fn changes_from_commit_message(
28 commit_message: &str,
29 scopes: Option<&Vec<String>>,
30 changelog_sections: &Sections,
31) -> Vec<Change> {
32 let Some(commit) = Commit::parse(commit_message.trim()).ok() else {
33 return Vec::new();
34 };
35 let mut has_breaking_footer = false;
36 let commit_summary = format_commit_summary(&commit);
37
38 if let Some(commit_scope) = commit.scope() {
39 if let Some(scopes) = scopes {
40 if !scopes
41 .iter()
42 .any(|s| s.eq_ignore_ascii_case(commit_scope.as_str()))
43 {
44 return Vec::new();
45 }
46 }
47 }
48
49 let mut changes = Vec::new();
50 for footer in commit.footers() {
51 if footer.breaking() {
52 has_breaking_footer = true;
53 } else if !changelog_sections.contains_footer(footer) {
54 continue;
55 }
56 changes.push(Change {
57 change_type: footer.token().into(),
58 description: footer.value().into(),
59 original_source: ChangeSource::ConventionalCommit(format_commit_footer(
60 &commit_summary,
61 footer,
62 )),
63 });
64 }
65
66 let commit_description_change_type = if commit.breaking() && !has_breaking_footer {
67 ChangeType::Breaking
68 } else if commit.type_() == Type::FEAT {
69 ChangeType::Feature
70 } else if commit.type_() == Type::FIX {
71 ChangeType::Fix
72 } else {
73 return changes; };
75
76 changes.push(Change {
77 change_type: commit_description_change_type,
78 description: commit.description().into(),
79 original_source: ChangeSource::ConventionalCommit(commit_summary),
80 });
81
82 changes
83}
84
85fn format_commit_summary(commit: &Commit) -> String {
86 let commit_scope = commit
87 .scope()
88 .map(|s| s.to_string())
89 .map(|it| format!("({it})"))
90 .unwrap_or_default();
91 let bang = if commit.breaking() {
92 commit
93 .footers()
94 .iter()
95 .find(|it| it.breaking())
96 .map_or_else(|| "!", |_| "")
97 } else {
98 ""
99 };
100 format!(
101 "{commit_type}{commit_scope}{bang}: {summary}",
102 commit_type = commit.type_(),
103 summary = commit.description()
104 )
105}
106
107fn format_commit_footer(commit_summary: &str, footer: &Footer) -> String {
108 format!(
109 "{commit_summary}\n\tContaining footer {}{} {}",
110 footer.token(),
111 footer.separator(),
112 footer.value()
113 )
114}
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used)]
118mod tests {
119 use itertools::Itertools;
120 use pretty_assertions::assert_eq;
121
122 use super::*;
123 use crate::{
124 changes::ChangeSource,
125 release_notes::{SectionSource, Sections},
126 };
127
128 #[test]
129 fn commit_types() {
130 let commits = &[
131 "fix: a bug",
132 "fix!: a breaking bug fix",
133 "feat!: add a feature",
134 "feat: add another feature",
135 ];
136 let changes =
137 changes_from_commit_messages(commits, None, &Sections::default()).collect_vec();
138 assert_eq!(
139 changes,
140 vec![
141 Change {
142 change_type: ChangeType::Fix,
143 description: "a bug".into(),
144 original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug")),
145 },
146 Change {
147 change_type: ChangeType::Breaking,
148 description: "a breaking bug fix".into(),
149 original_source: ChangeSource::ConventionalCommit(String::from(
150 "fix!: a breaking bug fix"
151 )),
152 },
153 Change {
154 change_type: ChangeType::Breaking,
155 description: "add a feature".into(),
156 original_source: ChangeSource::ConventionalCommit(String::from(
157 "feat!: add a feature"
158 )),
159 },
160 Change {
161 change_type: ChangeType::Feature,
162 description: "add another feature".into(),
163 original_source: ChangeSource::ConventionalCommit(String::from(
164 "feat: add another feature"
165 )),
166 }
167 ]
168 );
169 }
170
171 #[test]
172 fn separate_breaking_messages() {
173 let commits = [
174 "fix: a bug\n\nBREAKING CHANGE: something broke",
175 "feat: a features\n\nBREAKING CHANGE: something else broke",
176 ];
177 let changes =
178 changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
179 assert_eq!(
180 changes,
181 vec![
182 Change {
183 change_type: ChangeType::Breaking,
184 description: "something broke".into(),
185 original_source: ChangeSource::ConventionalCommit(String::from(
186 "fix: a bug\n\tContaining footer BREAKING CHANGE: something broke"
187 )),
188 },
189 Change {
190 change_type: ChangeType::Fix,
191 description: "a bug".into(),
192 original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug")),
193 },
194 Change {
195 change_type: ChangeType::Breaking,
196 description: "something else broke".into(),
197 original_source: ChangeSource::ConventionalCommit(String::from(
198 "feat: a features\n\tContaining footer BREAKING CHANGE: something else broke"
199 )),
200 },
201 Change {
202 change_type: ChangeType::Feature,
203 description: "a features".into(),
204 original_source: ChangeSource::ConventionalCommit(String::from(
205 "feat: a features"
206 )),
207 },
208 ]
209 );
210 }
211
212 #[test]
213 fn scopes_used_but_none_defined() {
214 let commits = [
215 "feat(scope)!: Wrong scope breaking change!",
216 "fix: No scope",
217 ];
218 let changes =
219 changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
220 assert_eq!(
221 changes,
222 vec![
223 Change {
224 change_type: ChangeType::Breaking,
225 description: "Wrong scope breaking change!".into(),
226 original_source: ChangeSource::ConventionalCommit(String::from(
227 "feat(scope)!: Wrong scope breaking change!"
228 )),
229 },
230 Change {
231 change_type: ChangeType::Fix,
232 description: "No scope".into(),
233 original_source: ChangeSource::ConventionalCommit(String::from(
234 "fix: No scope"
235 )),
236 }
237 ]
238 );
239 }
240
241 #[test]
242 fn filter_scopes() {
243 let commits = [
244 "feat(wrong_scope)!: Wrong scope breaking change!",
245 "feat(scope): Scoped feature",
246 "fix: No scope",
247 ];
248
249 let changes = changes_from_commit_messages(
250 &commits,
251 Some(&vec![String::from("scope")]),
252 &Sections::default(),
253 )
254 .collect_vec();
255 assert_eq!(
256 changes,
257 vec![
258 Change {
259 change_type: ChangeType::Feature,
260 description: "Scoped feature".into(),
261 original_source: ChangeSource::ConventionalCommit(String::from(
262 "feat(scope): Scoped feature"
263 )),
264 },
265 Change {
266 change_type: ChangeType::Fix,
267 description: "No scope".into(),
268 original_source: ChangeSource::ConventionalCommit(String::from(
269 "fix: No scope"
270 )),
271 },
272 ]
273 );
274 }
275
276 #[test]
277 fn custom_footers() {
278 let commits = ["chore: ignored type\n\nignored-footer: ignored\ncustom-footer: hello"];
279 let changelog_sections = Sections(vec![(
280 "custom section".into(),
281 vec![ChangeType::Custom(SectionSource::CommitFooter(
282 "custom-footer".into(),
283 ))],
284 )]);
285 let changes =
286 changes_from_commit_messages(&commits, None, &changelog_sections).collect_vec();
287 assert_eq!(
288 changes,
289 vec![Change {
290 change_type: ChangeType::Custom(SectionSource::CommitFooter(
291 "custom-footer".into()
292 )),
293 description: "hello".into(),
294 original_source: ChangeSource::ConventionalCommit(String::from(
295 "chore: ignored type\n\tContaining footer custom-footer: hello"
296 )),
297 }]
298 );
299 }
300}