use git_conventional::{Commit, Footer, Type};
use super::{Change, ChangeSource, ChangeType};
use crate::release_notes::Sections;
pub(crate) fn changes_from_commit_messages<'a, Message: AsRef<str>>(
commit_messages: &'a [Message],
scopes: Option<&'a Vec<String>>,
changelog_sections: &'a Sections,
) -> impl Iterator<Item = Change> + 'a {
commit_messages.iter().flat_map(move |message| {
changes_from_commit_message(message.as_ref(), scopes, changelog_sections).into_iter()
})
}
fn changes_from_commit_message(
commit_message: &str,
scopes: Option<&Vec<String>>,
changelog_sections: &Sections,
) -> Vec<Change> {
let Some(commit) = Commit::parse(commit_message.trim()).ok() else {
return Vec::new();
};
let mut has_breaking_footer = false;
let commit_summary = format_commit_summary(&commit);
if let Some(commit_scope) = commit.scope() {
if let Some(scopes) = scopes {
if !scopes
.iter()
.any(|s| s.eq_ignore_ascii_case(commit_scope.as_str()))
{
return Vec::new();
}
}
}
let mut changes = Vec::new();
for footer in commit.footers() {
if footer.breaking() {
has_breaking_footer = true;
} else if !changelog_sections.contains_footer(footer) {
continue;
}
changes.push(Change {
change_type: footer.token().into(),
description: footer.value().into(),
original_source: ChangeSource::ConventionalCommit(format_commit_footer(
&commit_summary,
footer,
)),
});
}
let commit_description_change_type = if commit.breaking() && !has_breaking_footer {
ChangeType::Breaking
} else if commit.type_() == Type::FEAT {
ChangeType::Feature
} else if commit.type_() == Type::FIX {
ChangeType::Fix
} else {
return changes; };
changes.push(Change {
change_type: commit_description_change_type,
description: commit.description().into(),
original_source: ChangeSource::ConventionalCommit(commit_summary),
});
changes
}
fn format_commit_summary(commit: &Commit) -> String {
let commit_scope = commit
.scope()
.map(|s| s.to_string())
.map(|it| format!("({it})"))
.unwrap_or_default();
let bang = if commit.breaking() {
commit
.footers()
.iter()
.find(|it| it.breaking())
.map_or_else(|| "!", |_| "")
} else {
""
};
format!(
"{commit_type}{commit_scope}{bang}: {summary}",
commit_type = commit.type_(),
summary = commit.description()
)
}
fn format_commit_footer(commit_summary: &str, footer: &Footer) -> String {
format!(
"{commit_summary}\n\tContaining footer {}{} {}",
footer.token(),
footer.separator(),
footer.value()
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use itertools::Itertools;
use pretty_assertions::assert_eq;
use super::*;
use crate::{
changes::ChangeSource,
release_notes::{SectionSource, Sections},
};
#[test]
fn commit_types() {
let commits = &[
"fix: a bug",
"fix!: a breaking bug fix",
"feat!: add a feature",
"feat: add another feature",
];
let changes =
changes_from_commit_messages(commits, None, &Sections::default()).collect_vec();
assert_eq!(
changes,
vec![
Change {
change_type: ChangeType::Fix,
description: "a bug".into(),
original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug")),
},
Change {
change_type: ChangeType::Breaking,
description: "a breaking bug fix".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"fix!: a breaking bug fix"
)),
},
Change {
change_type: ChangeType::Breaking,
description: "add a feature".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"feat!: add a feature"
)),
},
Change {
change_type: ChangeType::Feature,
description: "add another feature".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"feat: add another feature"
)),
}
]
);
}
#[test]
fn separate_breaking_messages() {
let commits = [
"fix: a bug\n\nBREAKING CHANGE: something broke",
"feat: a features\n\nBREAKING CHANGE: something else broke",
];
let changes =
changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
assert_eq!(
changes,
vec![
Change {
change_type: ChangeType::Breaking,
description: "something broke".into(),
original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug\n\tContaining footer BREAKING CHANGE: something broke")),
},
Change {
change_type: ChangeType::Fix,
description: "a bug".into(),
original_source: ChangeSource::ConventionalCommit(String::from("fix: a bug")),
},
Change {
change_type: ChangeType::Breaking,
description: "something else broke".into(),
original_source: ChangeSource::ConventionalCommit(String::from("feat: a features\n\tContaining footer BREAKING CHANGE: something else broke")),
},
Change {
change_type: ChangeType::Feature,
description: "a features".into(),
original_source: ChangeSource::ConventionalCommit(String::from("feat: a features")),
},
]
);
}
#[test]
fn scopes_used_but_none_defined() {
let commits = [
"feat(scope)!: Wrong scope breaking change!",
"fix: No scope",
];
let changes =
changes_from_commit_messages(&commits, None, &Sections::default()).collect_vec();
assert_eq!(
changes,
vec![
Change {
change_type: ChangeType::Breaking,
description: "Wrong scope breaking change!".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"feat(scope)!: Wrong scope breaking change!"
)),
},
Change {
change_type: ChangeType::Fix,
description: "No scope".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"fix: No scope"
)),
}
]
);
}
#[test]
fn filter_scopes() {
let commits = [
"feat(wrong_scope)!: Wrong scope breaking change!",
"feat(scope): Scoped feature",
"fix: No scope",
];
let changes = changes_from_commit_messages(
&commits,
Some(&vec![String::from("scope")]),
&Sections::default(),
)
.collect_vec();
assert_eq!(
changes,
vec![
Change {
change_type: ChangeType::Feature,
description: "Scoped feature".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"feat(scope): Scoped feature"
)),
},
Change {
change_type: ChangeType::Fix,
description: "No scope".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"fix: No scope"
)),
},
]
);
}
#[test]
fn custom_footers() {
let commits = ["chore: ignored type\n\nignored-footer: ignored\ncustom-footer: hello"];
let changelog_sections = Sections(vec![(
"custom section".into(),
vec![ChangeType::Custom(SectionSource::CommitFooter(
"custom-footer".into(),
))],
)]);
let changes =
changes_from_commit_messages(&commits, None, &changelog_sections).collect_vec();
assert_eq!(
changes,
vec![Change {
change_type: ChangeType::Custom(SectionSource::CommitFooter(
"custom-footer".into()
)),
description: "hello".into(),
original_source: ChangeSource::ConventionalCommit(String::from(
"chore: ignored type\n\tContaining footer custom-footer: hello"
)),
}]
);
}
}