#[derive(Debug, PartialEq, Eq)]
pub struct CommitMessage {
pub commit_type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub footer: Option<String>,
pub breaking: bool,
}
impl CommitMessage {
pub fn parse(input: &str) -> Result<Self, String> {
let parts: Vec<&str> = input.split("\n\n").collect();
if parts.is_empty() {
return Err("Empty commit message".to_string());
}
let header = parts[0].trim();
let (commit_type, scope, description) = Self::parse_header(header)?;
let body = if parts.len() > 1 && !parts[1].trim().is_empty() {
Some(parts[1].trim().to_string())
} else {
None
};
let (footer, breaking) = if parts.len() > 2 && !parts[2].trim().is_empty() {
let footer_text = parts[2].trim().to_string();
let is_breaking = footer_text.contains('!');
(Some(footer_text), is_breaking)
} else {
(None, false)
};
Ok(CommitMessage {
commit_type,
scope,
description,
body,
footer,
breaking,
})
}
fn parse_header(header: &str) -> Result<(String, Option<String>, String), String> {
let colon_index = header.find(':').ok_or("Missing ':' in header")?;
let (meta, description) = header.split_at(colon_index);
let description = description[1..].trim();
if let Some(start) = meta.find('(') {
let end = meta.find(')').ok_or("Missing closing ')' in header")?;
let commit_type = meta[..start].trim().to_string();
let scope = meta[start + 1..end].trim().to_string();
if scope.is_empty() {
return Err("Empty scope in header".into());
}
Ok((commit_type, Some(scope), description.to_string()))
} else {
Ok((meta.trim().to_string(), None, description.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_with_scope_and_footer_breaking() {
let input = "\
feat(ui): add new button
This commit adds a new button to the UI.
BREAKING CHANGE!: The button API has changed.";
let commit = CommitMessage::parse(input).unwrap();
assert_eq!(commit.commit_type, "feat");
assert_eq!(commit.scope, Some("ui".into()));
assert_eq!(commit.description, "add new button");
assert_eq!(
commit.body,
Some("This commit adds a new button to the UI.".into())
);
assert!(commit.breaking);
}
#[test]
fn test_parse_without_scope_and_footer() {
let input = "fix: correct typo";
let commit = CommitMessage::parse(input).unwrap();
assert_eq!(commit.commit_type, "fix");
assert_eq!(commit.scope, None);
assert_eq!(commit.description, "correct typo");
assert_eq!(commit.body, None);
assert_eq!(commit.footer, None);
assert!(!commit.breaking);
}
#[test]
fn test_parse_with_empty_body_and_footer() {
let input = "docs(api): update documentation\n\n\n";
let commit = CommitMessage::parse(input).unwrap();
assert_eq!(commit.commit_type, "docs");
assert_eq!(commit.scope, Some("api".into()));
assert_eq!(commit.description, "update documentation");
assert_eq!(commit.body, None);
assert_eq!(commit.footer, None);
assert!(!commit.breaking);
}
#[test]
fn test_parse_error_missing_colon() {
let input = "chore update dependencies";
let err = CommitMessage::parse(input).unwrap_err();
assert!(err.contains("Missing ':'"));
}
}
pub mod changelog {
pub const TYPES: &[&str] = &[
"feat", "fix", "docs", "style", "refactor", "test", "chore", ];
pub const SCOPES: &[&str] = &["core", "ui", "api", "build", "docs", "tests"];
pub const BREAKING_CHANGE_MARKER: &str = "!";
#[derive(Debug)]
pub struct Changelog {
pub types: &'static [&'static str],
pub scopes: &'static [&'static str],
pub breaking_marker: &'static str,
}
pub fn get_changelog() -> Changelog {
Changelog {
types: TYPES,
scopes: SCOPES,
breaking_marker: BREAKING_CHANGE_MARKER,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_changelog_contents() {
let changelog = get_changelog();
assert!(changelog.types.contains(&"feat"));
assert!(changelog.scopes.contains(&"ui"));
assert_eq!(changelog.breaking_marker, "!");
}
}
}