use crate::Error;
#[derive(Debug)]
pub struct CmtSummary {
pub title: String,
pub emoji: Option<String>,
pub type_: Option<String>,
pub scope: Option<String>,
pub breaking: bool,
}
impl CmtSummary {
pub fn parse(title: &str) -> Result<Self, Error> {
let re = regex::Regex::new(
r"^(?P<emoji>.+\s)?(?P<type>[a-z]+)(?:\((?P<scope>.+)\))?(?P<breaking>!)?: (?P<description>.*)$$",
)?;
log::debug!("String to parse: `{title}`");
let cmt_summary = if let Some(captures) = re.captures(title) {
log::debug!("Captures: {captures:#?}");
let emoji = captures.name("emoji").map(|m| m.as_str().to_string());
let type_ = captures.name("type").map(|m| m.as_str().to_string());
let scope = captures.name("scope").map(|m| m.as_str().to_string());
let breaking = captures.name("breaking").is_some();
let title = captures
.name("description")
.map(|m| m.as_str().to_string())
.unwrap();
Self {
title,
emoji,
type_,
scope,
breaking,
}
} else {
Self {
title: title.to_string(),
emoji: None,
type_: None,
scope: None,
breaking: false,
}
};
log::debug!("Parsed title: {cmt_summary:?}");
Ok(cmt_summary)
}
pub fn type_string(&self) -> String {
self.type_.clone().unwrap_or_default()
}
pub fn is_major_dep_bump(&self) -> bool {
let type_matches = matches!(self.type_.as_deref(), Some("fix") | Some("chore"));
let scope_has_deps = self
.scope
.as_deref()
.map(|s| s.contains("deps"))
.unwrap_or(false);
if !type_matches || !scope_has_deps {
return false;
}
if self.title.contains("(major)") {
return true;
}
let re = regex::Regex::new(r"(?i)\bv(\d+)\.").expect("valid regex");
for cap in re.captures_iter(&self.title) {
if let Some(m) = cap.get(1) {
if let Ok(major) = m.as_str().parse::<u64>() {
if major >= 2 {
return true;
}
}
}
}
false
}
}
impl std::fmt::Display for CmtSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}{}{}{}: {}",
self.emoji.clone().unwrap_or_default(),
self.type_.clone().unwrap_or_default(),
self.scope
.as_ref()
.map_or("".to_string(), |s| format!("({s})")),
if self.breaking { "!" } else { "" },
self.title
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use color_eyre::Result;
use log::LevelFilter;
use rstest::rstest;
fn get_test_logger() {
let mut builder = env_logger::Builder::new();
builder.filter(None, LevelFilter::Debug);
builder.format_timestamp_secs().format_module_path(false);
let _ = builder.try_init();
}
#[test]
fn test_cmt_summary_parse() {
let cmt_summary = CmtSummary::parse("feat: add new feature").unwrap();
assert_eq!(cmt_summary.title, "add new feature");
assert_eq!(cmt_summary.type_, Some("feat".to_string()));
assert_eq!(cmt_summary.scope, None);
assert!(!cmt_summary.breaking);
let cmt_summary = CmtSummary::parse("feat(core): add new feature").unwrap();
assert_eq!(cmt_summary.title, "add new feature");
assert_eq!(cmt_summary.type_, Some("feat".to_string()));
assert_eq!(cmt_summary.scope, Some("core".to_string()));
assert!(!cmt_summary.breaking);
let cmt_summary = CmtSummary::parse("feat(core)!: add new feature").unwrap();
assert_eq!(cmt_summary.title, "add new feature");
assert_eq!(cmt_summary.type_, Some("feat".to_string()));
assert_eq!(cmt_summary.scope, Some("core".to_string()));
assert!(cmt_summary.breaking);
}
#[test]
fn test_cmt_summary_parse_with_breaking_scope() {
let cmt_summary = CmtSummary::parse("feat(core)!: add new feature").unwrap();
assert_eq!(cmt_summary.title, "add new feature");
assert_eq!(cmt_summary.type_, Some("feat".to_string()));
assert_eq!(cmt_summary.scope, Some("core".to_string()));
assert!(cmt_summary.breaking);
}
#[test]
fn test_cmt_summary_parse_with_security_scope() {
let cmt_summary = CmtSummary::parse("fix(security): fix security vulnerability").unwrap();
assert_eq!(cmt_summary.title, "fix security vulnerability");
assert_eq!(cmt_summary.type_, Some("fix".to_string()));
assert_eq!(cmt_summary.scope, Some("security".to_string()));
assert!(!cmt_summary.breaking);
}
#[test]
fn test_cmt_summary_parse_with_deprecate_scope() {
let cmt_summary = CmtSummary::parse("chore(deprecate): deprecate old feature").unwrap();
assert_eq!(cmt_summary.title, "deprecate old feature");
assert_eq!(cmt_summary.type_, Some("chore".to_string()));
assert_eq!(cmt_summary.scope, Some("deprecate".to_string()));
assert!(!cmt_summary.breaking);
}
#[test]
fn test_cmt_summary_parse_without_scope() {
let cmt_summary = CmtSummary::parse("docs: update documentation").unwrap();
assert_eq!(cmt_summary.title, "update documentation");
assert_eq!(cmt_summary.type_, Some("docs".to_string()));
assert_eq!(cmt_summary.scope, None);
assert!(!cmt_summary.breaking);
}
#[test]
fn test_cmt_summary_parse_issue_172() {
let cmt_summary = CmtSummary::parse(
"chore(config.yml): update jerus-org/circleci-toolkit orb version to 0.4.0",
)
.unwrap();
assert_eq!(
cmt_summary.title,
"update jerus-org/circleci-toolkit orb version to 0.4.0"
);
assert_eq!(cmt_summary.type_, Some("chore".to_string()));
assert_eq!(cmt_summary.scope, Some("config.yml".to_string()));
assert!(!cmt_summary.breaking);
}
#[rstest]
#[case("fix(deps): update serde to v2.0.0", true)]
#[case("fix(deps): update serde to v1.0.200", false)]
#[case("chore(deps): bump tokio (major)", true)]
#[case("feat: add new feature", false)]
#[case("fix(deps): update serde to v0.9.0 to v0.10.0", false)]
#[case("fix(deps): update serde to v10.0.0", true)]
#[case("chore(deps): bump serde from v1.0.0 to v2.0.0", true)]
#[case("fix(security): fix security vulnerability", false)]
#[case("chore(config): update settings", false)]
fn test_is_major_dep_bump(#[case] title: &str, #[case] expected: bool) -> Result<()> {
get_test_logger();
let cmt_summary = CmtSummary::parse(title).unwrap();
assert_eq!(expected, cmt_summary.is_major_dep_bump(), "commit: {title}");
Ok(())
}
#[rstest]
#[case("feat: add new feature", "feat")]
#[case("✨ feat: add new feature", "feat")]
#[case("feat: add new feature", "feat")]
#[case("feat: add new feature", "feat")]
#[case("feat: add new feature", "feat")]
#[case("✨ feat: add new feature", "feat")]
#[case("fix: fix an existing feature", "fix")]
#[case("🐛 fix: fix an existing feature", "fix")]
#[case("style: fix typo and lint issues", "style")]
#[case("💄 style: fix typo and lint issues", "style")]
#[case("test: update tests", "test")]
#[case("fix(security): Fix security vulnerability", "fix")]
#[case("chore(deps): Update dependencies", "chore")]
#[case("🔧 chore(deps): Update dependencies", "chore")]
#[case("refactor(remove): Remove unused code", "refactor")]
#[case("♻️ refactor(remove): Remove unused code", "refactor")]
#[case("docs(deprecate): Deprecate old API", "docs")]
#[case("📚 docs(deprecate): Deprecate old API", "docs")]
#[case("ci(other-scope): Update CI configuration", "ci")]
#[case("👷 ci(other-scope): Update CI configuration", "ci")]
#[case("test!: Update test cases", "test")]
#[case::issue_172(
"chore(config.yml): update jerus-org/circleci-toolkit orb version to 0.4.0",
"chore"
)]
#[case::with_emoji("✨ feat(ci): add optional flag for push failure handling", "feat")]
fn test_calculate_kind_and_description(
#[case] title: &str,
#[case] expected_type: &str,
) -> Result<()> {
get_test_logger();
let cmt_summary = CmtSummary::parse(title).unwrap();
assert_eq!(expected_type, &cmt_summary.type_.unwrap());
Ok(())
}
}