1use git_conventional::{Commit as ConventionalCommit, Footer, Type};
2use tracing::debug;
3
4use super::{Change, ChangeSource, ChangeType, GitInfo};
5use crate::release_notes::Sections;
6
7#[derive(Clone, Debug, Default)]
8pub struct Commit {
9 pub message: String,
10 pub info: Option<GitInfo>,
11}
12
13pub(crate) fn changes_from_commit_messages<'a>(
21 commit_messages: &'a [Commit],
22 scopes: Option<&'a Vec<String>>,
23 changelog_sections: &'a Sections,
24) -> impl Iterator<Item = Change> + 'a {
25 if let Some(scopes) = scopes {
26 debug!("Only checking commits with scopes: {scopes:?}");
27 }
28 commit_messages.iter().flat_map(move |commit| {
29 changes_from_commit_message(commit, scopes, changelog_sections).into_iter()
30 })
31}
32
33fn changes_from_commit_message(
34 commit_info: &Commit,
35 scopes: Option<&Vec<String>>,
36 changelog_sections: &Sections,
37) -> Vec<Change> {
38 let Some(commit) = ConventionalCommit::parse(commit_info.message.trim()).ok() else {
39 return Vec::new();
40 };
41 let mut has_breaking_footer = false;
42 let commit_summary = format_commit_summary(&commit);
43
44 if let Some(commit_scope) = commit.scope() {
45 if let Some(scopes) = scopes {
46 if !scopes
47 .iter()
48 .any(|s| s.eq_ignore_ascii_case(commit_scope.as_str()))
49 {
50 return Vec::new();
51 }
52 }
53 }
54
55 let mut changes = Vec::new();
56 for footer in commit.footers() {
57 if footer.breaking() {
58 has_breaking_footer = true;
59 } else if !changelog_sections.contains_footer(footer) {
60 continue;
61 }
62 changes.push(Change {
63 change_type: footer.token().into(),
64 summary: footer.value().into(),
65 details: None,
66 original_source: ChangeSource::ConventionalCommit {
67 description: format_commit_footer(&commit_summary, footer),
68 },
69 git: commit_info.info.clone(),
70 });
71 }
72
73 let commit_description_change_type = if commit.breaking() && !has_breaking_footer {
74 ChangeType::Breaking
75 } else if commit.type_() == Type::FEAT {
76 ChangeType::Feature
77 } else if commit.type_() == Type::FIX {
78 ChangeType::Fix
79 } else {
80 return changes; };
82
83 changes.push(Change {
84 change_type: commit_description_change_type,
85 summary: commit.description().into(),
86 details: None,
87 original_source: ChangeSource::ConventionalCommit {
88 description: commit_summary,
89 },
90 git: commit_info.info.clone(),
91 });
92
93 changes
94}
95
96fn format_commit_summary(commit: &ConventionalCommit) -> String {
97 let commit_scope = commit
98 .scope()
99 .map(|s| s.to_string())
100 .map(|it| format!("({it})"))
101 .unwrap_or_default();
102 let bang = if commit.breaking() {
103 commit
104 .footers()
105 .iter()
106 .find(|it| it.breaking())
107 .map_or_else(|| "!", |_| "")
108 } else {
109 ""
110 };
111 format!(
112 "{commit_type}{commit_scope}{bang}: {summary}",
113 commit_type = commit.type_(),
114 summary = commit.description()
115 )
116}
117
118fn format_commit_footer(commit_summary: &str, footer: &Footer) -> String {
119 format!(
120 "{commit_summary}\n\tContaining footer {}{} {}",
121 footer.token(),
122 footer.separator(),
123 footer.value()
124 )
125}
126
127#[cfg(test)]
128#[allow(clippy::unwrap_used)]
129mod tests {
130 use itertools::Itertools;
131 use pretty_assertions::assert_eq;
132
133 use super::*;
134 use crate::{
135 changes::ChangeSource,
136 release_notes::{SectionSource, Sections},
137 };
138
139 #[test]
140 fn commit_types() {
141 let commits = &[
142 Commit {
143 message: "fix: a bug".to_string(),
144 ..Default::default()
145 },
146 Commit {
147 message: "fix!: a breaking bug fix".to_string(),
148 ..Default::default()
149 },
150 Commit {
151 message: "feat!: add a feature".to_string(),
152 ..Default::default()
153 },
154 Commit {
155 message: "feat: add another feature".to_string(),
156 ..Default::default()
157 },
158 ];
159 let changes =
160 changes_from_commit_messages(commits, None, &Sections::default()).collect_vec();
161 assert_eq!(
162 changes,
163 vec![
164 Change {
165 change_type: ChangeType::Fix,
166 summary: "a bug".into(),
167 details: None,
168 original_source: ChangeSource::ConventionalCommit {
169 description: String::from("fix: a bug"),
170 },
171 git: None,
172 },
173 Change {
174 change_type: ChangeType::Breaking,
175 summary: "a breaking bug fix".into(),
176 details: None,
177 original_source: ChangeSource::ConventionalCommit {
178 description: String::from("fix!: a breaking bug fix"),
179 },
180 git: None,
181 },
182 Change {
183 change_type: ChangeType::Breaking,
184 summary: "add a feature".into(),
185 details: None,
186 original_source: ChangeSource::ConventionalCommit {
187 description: String::from("feat!: add a feature"),
188 },
189 git: None,
190 },
191 Change {
192 change_type: ChangeType::Feature,
193 summary: "add another feature".into(),
194 details: None,
195 original_source: ChangeSource::ConventionalCommit {
196 description: String::from("feat: add another feature"),
197 },
198 git: None,
199 }
200 ]
201 );
202 }
203
204 #[test]
205 fn separate_breaking_messages() {
206 let commits = [
207 Commit {
208 message: "fix: a bug\n\nBREAKING CHANGE: something broke".to_string(),
209 ..Default::default()
210 },
211 Commit {
212 message: "feat: a features\n\nBREAKING CHANGE: something else broke".to_string(),
213 ..Default::default()
214 },
215 ];
216 let changes =
217 changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
218 assert_eq!(
219 changes,
220 vec![
221 Change {
222 change_type: ChangeType::Breaking,
223 summary: "something broke".into(),
224 details: None,
225 original_source: ChangeSource::ConventionalCommit {
226 description: String::from(
227 "fix: a bug\n\tContaining footer BREAKING CHANGE: something broke"
228 ),
229 },
230 git: None,
231 },
232 Change {
233 change_type: ChangeType::Fix,
234 summary: "a bug".into(),
235 details: None,
236 original_source: ChangeSource::ConventionalCommit {
237 description: String::from("fix: a bug"),
238 },
239 git: None,
240 },
241 Change {
242 change_type: ChangeType::Breaking,
243 summary: "something else broke".into(),
244 details: None,
245 original_source: ChangeSource::ConventionalCommit {
246 description: String::from(
247 "feat: a features\n\tContaining footer BREAKING CHANGE: something else broke"
248 ),
249 },
250 git: None,
251 },
252 Change {
253 change_type: ChangeType::Feature,
254 summary: "a features".into(),
255 details: None,
256 original_source: ChangeSource::ConventionalCommit {
257 description: String::from("feat: a features"),
258 },
259 git: None,
260 },
261 ]
262 );
263 }
264
265 #[test]
266 fn scopes_used_but_none_defined() {
267 let commits = [
268 Commit {
269 message: "feat(scope)!: Wrong scope breaking change!".to_string(),
270 ..Default::default()
271 },
272 Commit {
273 message: "fix: No scope".to_string(),
274 ..Default::default()
275 },
276 ];
277 let changes =
278 changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
279 assert_eq!(
280 changes,
281 vec![
282 Change {
283 change_type: ChangeType::Breaking,
284 summary: "Wrong scope breaking change!".into(),
285 details: None,
286 original_source: ChangeSource::ConventionalCommit {
287 description: String::from("feat(scope)!: Wrong scope breaking change!"),
288 },
289 git: None,
290 },
291 Change {
292 change_type: ChangeType::Fix,
293 summary: "No scope".into(),
294 details: None,
295 original_source: ChangeSource::ConventionalCommit {
296 description: String::from("fix: No scope"),
297 },
298 git: None,
299 }
300 ]
301 );
302 }
303
304 #[test]
305 fn filter_scopes() {
306 let commits = [
307 Commit {
308 message: "feat(wrong_scope)!: Wrong scope breaking change!".to_string(),
309 ..Default::default()
310 },
311 Commit {
312 message: "feat(scope): Scoped feature".to_string(),
313 ..Default::default()
314 },
315 Commit {
316 message: "fix: No scope".to_string(),
317 ..Default::default()
318 },
319 ];
320
321 let changes = changes_from_commit_messages(
322 &commits,
323 Some(&vec![String::from("scope")]),
324 &Sections::default(),
325 )
326 .collect_vec();
327 assert_eq!(
328 changes,
329 vec![
330 Change {
331 change_type: ChangeType::Feature,
332 summary: "Scoped feature".into(),
333 details: None,
334 original_source: ChangeSource::ConventionalCommit {
335 description: String::from("feat(scope): Scoped feature"),
336 },
337 git: None,
338 },
339 Change {
340 change_type: ChangeType::Fix,
341 summary: "No scope".into(),
342 details: None,
343 original_source: ChangeSource::ConventionalCommit {
344 description: String::from("fix: No scope"),
345 },
346 git: None,
347 },
348 ]
349 );
350 }
351
352 #[test]
353 fn custom_footers() {
354 let commits = [Commit {
355 message: "chore: ignored type\n\nignored-footer: ignored\ncustom-footer: hello"
356 .to_string(),
357 ..Default::default()
358 }];
359 let changelog_sections = Sections(vec![(
360 "custom section".into(),
361 vec![ChangeType::Custom(SectionSource::CommitFooter(
362 "custom-footer".into(),
363 ))],
364 )]);
365 let changes =
366 changes_from_commit_messages(&commits, None, &changelog_sections).collect_vec();
367 assert_eq!(
368 changes,
369 vec![Change {
370 change_type: ChangeType::Custom(SectionSource::CommitFooter(
371 "custom-footer".into()
372 )),
373 summary: "hello".into(),
374 details: None,
375 original_source: ChangeSource::ConventionalCommit {
376 description: String::from(
377 "chore: ignored type\n\tContaining footer custom-footer: hello"
378 ),
379 },
380 git: None,
381 }]
382 );
383 }
384}