use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
pub hash: String,
pub short_hash: String,
pub message: String,
pub author: String,
pub author_email: String,
pub date: DateTime<Utc>,
pub files_changed: Vec<String>,
pub commit_type: Option<CommitType>,
pub scope: Option<String>,
pub breaking: bool,
pub body: Option<String>,
}
impl Commit {
#[must_use]
pub fn new(
hash: String,
message: String,
author: String,
author_email: String,
date: DateTime<Utc>,
) -> Self {
let short_hash = hash.chars().take(7).collect();
let (commit_type, scope, breaking) = Self::parse_conventional(&message);
Self {
hash,
short_hash,
message,
author,
author_email,
date,
files_changed: Vec::new(),
commit_type,
scope,
breaking,
body: None,
}
}
fn parse_conventional(message: &str) -> (Option<CommitType>, Option<String>, bool) {
let parts: Vec<&str> = message.splitn(2, ':').collect();
if parts.len() < 2 {
return (None, None, false);
}
let prefix = parts[0].trim();
let type_str = prefix.trim_end_matches('!');
let scope = type_str.find(')').and_then(|end| {
type_str.find('(').and_then(|start| {
let scope_str = &type_str[start + 1..end];
if scope_str.is_empty() {
None
} else {
Some(scope_str.to_string())
}
})
});
let base_type = type_str
.find('(')
.map_or(type_str, |start| &type_str[..start]);
let breaking = prefix.ends_with('!');
let commit_type = CommitType::parse_from_str(base_type);
let has_breaking_body = message
.lines()
.skip(1)
.any(|line| line.to_uppercase().contains("BREAKING CHANGE"));
(commit_type, scope, breaking || has_breaking_body)
}
#[must_use]
pub const fn commit_type(&self) -> Option<CommitType> {
self.commit_type
}
#[must_use]
pub const fn is_conventional(&self) -> bool {
self.commit_type.is_some()
}
#[must_use]
pub fn affects_scope(&self, scope: &str) -> bool {
self.scope.as_deref() == Some(scope)
}
#[must_use]
pub fn is_type(&self, commit_type: CommitType) -> bool {
self.commit_type == Some(commit_type)
}
#[must_use]
pub fn short_message(&self) -> &str {
self.message.lines().next().unwrap_or(&self.message)
}
#[must_use]
pub fn changelog_message(&self) -> &str {
self.short_message().split_once(':').map_or_else(
|| self.short_message(),
|(_, description)| description.trim(),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitType {
Feat,
Fix,
Docs,
Style,
Refactor,
Perf,
Test,
Build,
Ci,
Chore,
Revert,
}
impl CommitType {
#[must_use]
pub fn parse_from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"feat" => Some(Self::Feat),
"fix" => Some(Self::Fix),
"docs" => Some(Self::Docs),
"style" => Some(Self::Style),
"refactor" => Some(Self::Refactor),
"perf" => Some(Self::Perf),
"test" => Some(Self::Test),
"build" => Some(Self::Build),
"ci" => Some(Self::Ci),
"chore" => Some(Self::Chore),
"revert" => Some(Self::Revert),
_ => None,
}
}
#[must_use]
pub const fn is_breaking(self) -> bool {
matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
}
#[must_use]
pub const fn is_feature(self) -> bool {
matches!(self, Self::Feat)
}
#[must_use]
pub const fn is_fix(self) -> bool {
matches!(self, Self::Fix | Self::Perf)
}
#[must_use]
pub const fn is_changeloggable(self) -> bool {
matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
}
}
impl std::fmt::Display for CommitType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Feat => write!(f, "feat"),
Self::Fix => write!(f, "fix"),
Self::Docs => write!(f, "docs"),
Self::Style => write!(f, "style"),
Self::Refactor => write!(f, "refactor"),
Self::Perf => write!(f, "perf"),
Self::Test => write!(f, "test"),
Self::Build => write!(f, "build"),
Self::Ci => write!(f, "ci"),
Self::Chore => write!(f, "chore"),
Self::Revert => write!(f, "revert"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitHistory {
pub commits: Vec<Commit>,
pub since: Option<String>,
pub until: Option<String>,
}
impl CommitHistory {
#[must_use]
pub const fn new(commits: Vec<Commit>) -> Self {
Self {
commits,
since: None,
until: None,
}
}
#[must_use]
pub fn by_type(&self, commit_type: CommitType) -> Vec<&Commit> {
self.commits
.iter()
.filter(|c| c.commit_type == Some(commit_type))
.collect()
}
#[must_use]
pub fn breaking_changes(&self) -> Vec<&Commit> {
self.commits.iter().filter(|c| c.breaking).collect()
}
#[must_use]
pub fn features(&self) -> Vec<&Commit> {
self.commits
.iter()
.filter(|c| c.commit_type == Some(CommitType::Feat))
.collect()
}
#[must_use]
pub fn fixes(&self) -> Vec<&Commit> {
self.commits
.iter()
.filter(|c| c.commit_type == Some(CommitType::Fix))
.collect()
}
#[must_use]
pub fn changeloggable(&self) -> Vec<&Commit> {
self.commits
.iter()
.filter(|c| c.commit_type.is_some_and(CommitType::is_changeloggable))
.collect()
}
#[must_use]
pub fn count_by_type(&self) -> std::collections::HashMap<CommitType, usize> {
let mut counts = std::collections::HashMap::new();
for commit in &self.commits {
if let Some(ct) = commit.commit_type {
*counts.entry(ct).or_insert(0) += 1;
}
}
counts
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.commits.is_empty()
}
#[must_use]
pub const fn len(&self) -> usize {
self.commits.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conventional_commit_parsing() {
let commit = Commit::new(
"abc123".to_string(),
"feat: add new feature".to_string(),
"Author".to_string(),
"author@example.com".to_string(),
Utc::now(),
);
assert_eq!(commit.commit_type, Some(CommitType::Feat));
assert!(!commit.breaking);
assert!(commit.is_conventional());
}
#[test]
fn test_breaking_commit_parsing() {
let commit = Commit::new(
"abc123".to_string(),
"feat!: breaking API change".to_string(),
"Author".to_string(),
"author@example.com".to_string(),
Utc::now(),
);
assert_eq!(commit.commit_type, Some(CommitType::Feat));
assert!(commit.breaking);
}
#[test]
fn test_scope_parsing() {
let commit = Commit::new(
"abc123".to_string(),
"feat(api): add new endpoint".to_string(),
"Author".to_string(),
"author@example.com".to_string(),
Utc::now(),
);
assert_eq!(commit.commit_type, Some(CommitType::Feat));
assert_eq!(commit.scope, Some("api".to_string()));
assert!(commit.affects_scope("api"));
}
#[test]
fn test_non_conventional_commit() {
let commit = Commit::new(
"abc123".to_string(),
"Add some stuff".to_string(),
"Author".to_string(),
"author@example.com".to_string(),
Utc::now(),
);
assert_eq!(commit.commit_type, None);
assert!(!commit.is_conventional());
}
#[test]
fn test_commit_history_filtering() {
let commits = vec![
Commit::new(
"1".to_string(),
"feat: feature 1".to_string(),
"A".to_string(),
"a@a.com".to_string(),
Utc::now(),
),
Commit::new(
"2".to_string(),
"fix: bug fix".to_string(),
"A".to_string(),
"a@a.com".to_string(),
Utc::now(),
),
Commit::new(
"3".to_string(),
"feat: feature 2".to_string(),
"A".to_string(),
"a@a.com".to_string(),
Utc::now(),
),
];
let history = CommitHistory::new(commits);
assert_eq!(history.features().len(), 2);
assert_eq!(history.fixes().len(), 1);
assert_eq!(history.changeloggable().len(), 3);
}
}