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(
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}