knope_versioning/changes/
conventional_commit.rs

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
13/// Try to parse each commit message as a [conventional commit](https://www.conventionalcommits.org/).
14///
15/// # Filtering
16///
17/// 1. If the commit message doesn't follow the conventional commit format, it is ignored.
18/// 2. For non-standard change types, only those included will be considered.
19/// 3. For non-standard footers, only those included will be considered.
20pub(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; // The commit description isn't a change itself, only (maybe) footers were.
81    };
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}