knope_versioning/changes/
conventional_commit.rs

1use git_conventional::{Commit, Footer, Type};
2use tracing::debug;
3
4use super::{Change, ChangeSource, ChangeType};
5use crate::release_notes::Sections;
6
7/// Try to parse each commit message as a [conventional commit](https://www.conventionalcommits.org/).
8///
9/// # Filtering
10///
11/// 1. If the commit message doesn't follow the conventional commit format, it is ignored.
12/// 2. For non-standard change types, only those included will be considered.
13/// 3. For non-standard footers, only those included will be considered.
14pub(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; // The commit description isn't a change itself, only (maybe) footers were.
74    };
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("fix: a bug\n\tContaining footer BREAKING CHANGE: something broke")),
186                },
187                Change {
188                    change_type: ChangeType::Fix,
189                    description: "a bug".into(),
190                    original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug")),
191                },
192                Change {
193                    change_type: ChangeType::Breaking,
194                    description: "something else broke".into(),
195                    original_source: ChangeSource::ConventionalCommit(String::from("feat: a features\n\tContaining footer BREAKING CHANGE: something else broke")),
196                },
197                Change {
198                    change_type: ChangeType::Feature,
199                    description: "a features".into(),
200                    original_source: ChangeSource::ConventionalCommit(String::from("feat: a features")),
201                },
202            ]
203        );
204    }
205
206    #[test]
207    fn scopes_used_but_none_defined() {
208        let commits = [
209            "feat(scope)!: Wrong scope breaking change!",
210            "fix: No scope",
211        ];
212        let changes =
213            changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
214        assert_eq!(
215            changes,
216            vec![
217                Change {
218                    change_type: ChangeType::Breaking,
219                    description: "Wrong scope breaking change!".into(),
220                    original_source: ChangeSource::ConventionalCommit(String::from(
221                        "feat(scope)!: Wrong scope breaking change!"
222                    )),
223                },
224                Change {
225                    change_type: ChangeType::Fix,
226                    description: "No scope".into(),
227                    original_source: ChangeSource::ConventionalCommit(String::from(
228                        "fix: No scope"
229                    )),
230                }
231            ]
232        );
233    }
234
235    #[test]
236    fn filter_scopes() {
237        let commits = [
238            "feat(wrong_scope)!: Wrong scope breaking change!",
239            "feat(scope): Scoped feature",
240            "fix: No scope",
241        ];
242
243        let changes = changes_from_commit_messages(
244            &commits,
245            Some(&vec![String::from("scope")]),
246            &Sections::default(),
247        )
248        .collect_vec();
249        assert_eq!(
250            changes,
251            vec![
252                Change {
253                    change_type: ChangeType::Feature,
254                    description: "Scoped feature".into(),
255                    original_source: ChangeSource::ConventionalCommit(String::from(
256                        "feat(scope): Scoped feature"
257                    )),
258                },
259                Change {
260                    change_type: ChangeType::Fix,
261                    description: "No scope".into(),
262                    original_source: ChangeSource::ConventionalCommit(String::from(
263                        "fix: No scope"
264                    )),
265                },
266            ]
267        );
268    }
269
270    #[test]
271    fn custom_footers() {
272        let commits = ["chore: ignored type\n\nignored-footer: ignored\ncustom-footer: hello"];
273        let changelog_sections = Sections(vec![(
274            "custom section".into(),
275            vec![ChangeType::Custom(SectionSource::CommitFooter(
276                "custom-footer".into(),
277            ))],
278        )]);
279        let changes =
280            changes_from_commit_messages(&commits, None, &changelog_sections).collect_vec();
281        assert_eq!(
282            changes,
283            vec![Change {
284                change_type: ChangeType::Custom(SectionSource::CommitFooter(
285                    "custom-footer".into()
286                )),
287                description: "hello".into(),
288                original_source: ChangeSource::ConventionalCommit(String::from(
289                    "chore: ignored type\n\tContaining footer custom-footer: hello"
290                )),
291            }]
292        );
293    }
294}