use regex::Regex;
use serde::Serialize;
use crate::error::Result;
use crate::utils::command;
const DOCS_FILE_EXTENSIONS: [&str; 1] = [".md"];
const DOCS_DIRECTORIES: [&str; 1] = ["docs/"];
#[derive(Debug, Clone, Serialize)]
pub struct CommitInfo {
pub hash: String,
pub subject: String,
pub category: CommitCategory,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum CommitCategory {
Breaking,
Feature,
Fix,
Docs,
Chore,
Merge,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum SemverBump {
Patch,
Minor,
Major,
}
impl SemverBump {
pub fn as_str(&self) -> &'static str {
match self {
SemverBump::Patch => "patch",
SemverBump::Minor => "minor",
SemverBump::Major => "major",
}
}
pub fn rank(&self) -> u8 {
match self {
SemverBump::Patch => 1,
SemverBump::Minor => 2,
SemverBump::Major => 3,
}
}
pub fn from_str(value: &str) -> Option<Self> {
match value {
"patch" => Some(SemverBump::Patch),
"minor" => Some(SemverBump::Minor),
"major" => Some(SemverBump::Major),
_ => None,
}
}
}
impl CommitCategory {
pub fn prefix(&self) -> Option<&'static str> {
match self {
CommitCategory::Breaking => Some("BREAKING"),
CommitCategory::Feature => Some("feat"),
CommitCategory::Fix => Some("fix"),
CommitCategory::Docs => Some("docs"),
CommitCategory::Chore => Some("chore"),
CommitCategory::Merge => None,
CommitCategory::Other => None,
}
}
pub fn to_changelog_entry_type(&self) -> Option<&'static str> {
match self {
CommitCategory::Feature => Some("added"),
CommitCategory::Fix => Some("fixed"),
CommitCategory::Breaking => Some("changed"),
CommitCategory::Docs => None,
CommitCategory::Chore => None,
CommitCategory::Merge => None,
CommitCategory::Other => Some("changed"),
}
}
}
pub fn parse_conventional_commit(subject: &str) -> CommitCategory {
let lower = subject.to_lowercase();
if lower.starts_with("merge pull request")
|| lower.starts_with("merge branch")
|| lower.starts_with("merge remote-tracking")
{
return CommitCategory::Merge;
}
if lower.contains("breaking change") || subject.contains("!:") {
CommitCategory::Breaking
} else if lower.starts_with("feat") {
CommitCategory::Feature
} else if lower.starts_with("fix") {
CommitCategory::Fix
} else if lower.starts_with("docs") {
CommitCategory::Docs
} else if lower.starts_with("chore") {
CommitCategory::Chore
} else {
CommitCategory::Other
}
}
pub(crate) fn extract_version_from_tag(tag: &str) -> Option<String> {
let version_pattern = Regex::new(r"v?(\d+\.\d+(?:\.\d+)?)").ok()?;
version_pattern
.captures(tag)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
pub fn get_latest_tag(path: &str) -> Result<Option<String>> {
Ok(command::run_in_optional(
path,
"git",
&["describe", "--tags", "--abbrev=0"],
))
}
pub fn find_version_commit(path: &str) -> Result<Option<String>> {
let stdout = command::run_in(path, "git", &["log", "-200", "--format=%h|%s"], "git log")?;
let version_pattern = Regex::new(
r"(?i)(?:^v|^version\s+(?:bump\s+(?:to\s+)?)?v?|^bump\s+(?:version\s+)?(?:to\s+)?v?|^(?:chore\([^)]*\):\s*)?release:?\s*v?)(\d+\.\d+(?:\.\d+)?)",
)
.expect("Invalid regex pattern");
for line in stdout.lines() {
if let Some((hash, subject)) = line.split_once('|') {
if version_pattern.is_match(subject) {
return Ok(Some(hash.to_string()));
}
}
}
Ok(None)
}
pub fn find_version_release_commit(path: &str, version: &str) -> Result<Option<String>> {
let Some(stdout) = command::run_in_optional(path, "git", &["log", "-200", "--format=%h|%s"])
else {
return Ok(None);
};
let escaped_version = regex::escape(version);
let patterns = [
format!(
r"(?i)^(?:chore\([^)]*\):\s*)?release:?\s*v?{}(?:\s|$)",
escaped_version
),
format!(r"(?i)^v?{}\s*$", escaped_version),
format!(
r"(?i)^bump\s+(?:version\s+)?(?:to\s+)?v?{}(?:\s|$)",
escaped_version
),
format!(
r"(?i)^version\s+(?:bump\s+(?:to\s+)?)?v?{}(?:\s|:|-|$)",
escaped_version
),
];
for line in stdout.lines() {
if let Some((hash, subject)) = line.split_once('|') {
for pattern in &patterns {
if Regex::new(pattern)
.map(|re| re.is_match(subject))
.unwrap_or(false)
{
return Ok(Some(hash.to_string()));
}
}
}
}
Ok(None)
}
pub fn get_last_n_commits(path: &str, n: usize) -> Result<Vec<CommitInfo>> {
let stdout = command::run_in(
path,
"git",
&["log", &format!("-{}", n), "--format=%h|%s"],
"git log",
)?;
let commits = stdout
.lines()
.filter_map(|line| {
let (hash, subject) = line.split_once('|')?;
Some(CommitInfo {
hash: hash.to_string(),
subject: subject.to_string(),
category: parse_conventional_commit(subject),
})
})
.collect();
Ok(commits)
}
pub fn get_commits_since_tag(path: &str, tag: Option<&str>) -> Result<Vec<CommitInfo>> {
let range = tag
.map(|t| format!("{}..HEAD", t))
.unwrap_or_else(|| "HEAD".to_string());
let stdout = command::run_in(path, "git", &["log", &range, "--format=%h|%s"], "git log")?;
let commits = stdout
.lines()
.filter_map(|line| {
let (hash, subject) = line.split_once('|')?;
Some(CommitInfo {
hash: hash.to_string(),
subject: subject.to_string(),
category: parse_conventional_commit(subject),
})
})
.collect();
Ok(commits)
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CommitCounts {
pub total: u32,
pub code: u32,
pub docs_only: u32,
}
pub fn get_commit_files(path: &str, commit_hash: &str) -> Result<Vec<String>> {
let stdout = command::run_in(
path,
"git",
&[
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
commit_hash,
],
"git diff-tree",
)?;
Ok(stdout
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect())
}
fn is_docs_file(file_path: &str) -> bool {
for ext in DOCS_FILE_EXTENSIONS {
if file_path.ends_with(ext) {
return true;
}
}
for dir in DOCS_DIRECTORIES {
if file_path.starts_with(dir) || file_path.contains(&format!("/{}", dir)) {
return true;
}
}
false
}
pub fn is_docs_only_commit(path: &str, commit: &CommitInfo) -> bool {
if commit.category == CommitCategory::Docs {
return true;
}
let files = match get_commit_files(path, &commit.hash) {
Ok(f) => f,
Err(_) => return false,
};
if files.is_empty() {
return false;
}
files.iter().all(|f| is_docs_file(f))
}
pub fn categorize_commits(path: &str, commits: &[CommitInfo]) -> CommitCounts {
let mut counts = CommitCounts {
total: commits.len() as u32,
code: 0,
docs_only: 0,
};
for commit in commits {
if is_docs_only_commit(path, commit) {
counts.docs_only += 1;
} else {
counts.code += 1;
}
}
counts
}
pub fn recommended_bump_from_commits(commits: &[CommitInfo]) -> Option<SemverBump> {
let mut recommended: Option<SemverBump> = None;
for commit in commits {
let bump = match commit.category {
CommitCategory::Breaking => SemverBump::Major,
CommitCategory::Feature => SemverBump::Minor,
CommitCategory::Fix | CommitCategory::Other => SemverBump::Patch,
CommitCategory::Docs | CommitCategory::Chore | CommitCategory::Merge => continue,
};
recommended = match recommended {
None => Some(bump),
Some(existing) if bump.rank() > existing.rank() => Some(bump),
Some(existing) => Some(existing),
};
}
recommended
}
pub fn strip_conventional_prefix(subject: &str) -> &str {
if let Some(pos) = subject.find(": ") {
let prefix = &subject[..pos];
if prefix
.chars()
.all(|c| c.is_alphanumeric() || c == '(' || c == ')' || c == '!')
{
return &subject[pos + 2..];
}
}
subject
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_conventional_commit_feat() {
assert_eq!(
parse_conventional_commit("feat: Add new feature"),
CommitCategory::Feature
);
assert_eq!(
parse_conventional_commit("feat(scope): Add scoped feature"),
CommitCategory::Feature
);
}
#[test]
fn parse_conventional_commit_fix() {
assert_eq!(
parse_conventional_commit("fix: Fix a bug"),
CommitCategory::Fix
);
}
#[test]
fn parse_conventional_commit_breaking() {
assert_eq!(
parse_conventional_commit("feat!: Breaking change"),
CommitCategory::Breaking
);
assert_eq!(
parse_conventional_commit("BREAKING CHANGE: Something big"),
CommitCategory::Breaking
);
}
#[test]
fn parse_conventional_commit_other() {
assert_eq!(
parse_conventional_commit("Random commit message"),
CommitCategory::Other
);
}
#[test]
fn strip_conventional_prefix_works() {
assert_eq!(
strip_conventional_prefix("feat: Add feature"),
"Add feature"
);
assert_eq!(
strip_conventional_prefix("fix(shell): Fix escaping"),
"Fix escaping"
);
assert_eq!(
strip_conventional_prefix("Regular commit"),
"Regular commit"
);
}
#[test]
fn is_docs_file_recognizes_markdown() {
assert!(is_docs_file("README.md"));
assert!(is_docs_file("CLAUDE.md"));
assert!(is_docs_file("changelog.md"));
assert!(is_docs_file("path/to/file.md"));
}
#[test]
fn is_docs_file_recognizes_docs_directory() {
assert!(is_docs_file("docs/guide.md"));
assert!(is_docs_file("docs/api/reference.md"));
assert!(is_docs_file("docs/commands/init.md"));
assert!(is_docs_file("src/docs/readme.txt"));
assert!(is_docs_file("path/to/docs/file.txt"));
}
#[test]
fn is_docs_file_rejects_code() {
assert!(!is_docs_file("src/main.rs"));
assert!(!is_docs_file("lib/extension.js"));
assert!(!is_docs_file("Cargo.toml"));
assert!(!is_docs_file("package.json"));
assert!(!is_docs_file("src/component.tsx"));
}
#[test]
fn parse_conventional_commit_docs() {
assert_eq!(
parse_conventional_commit("docs: Update README"),
CommitCategory::Docs
);
assert_eq!(
parse_conventional_commit("docs(api): Add endpoint docs"),
CommitCategory::Docs
);
}
#[test]
fn parse_conventional_commit_merge() {
assert_eq!(
parse_conventional_commit("Merge pull request #45 from feature-branch"),
CommitCategory::Merge
);
assert_eq!(
parse_conventional_commit("Merge branch 'main' into feature"),
CommitCategory::Merge
);
assert_eq!(
parse_conventional_commit("Merge remote-tracking branch 'origin/main'"),
CommitCategory::Merge
);
}
#[test]
fn merge_category_skipped_in_changelog() {
assert!(CommitCategory::Merge.to_changelog_entry_type().is_none());
assert!(CommitCategory::Docs.to_changelog_entry_type().is_none());
assert!(CommitCategory::Chore.to_changelog_entry_type().is_none());
assert!(CommitCategory::Feature.to_changelog_entry_type().is_some());
}
#[test]
fn recommended_bump_prefers_highest_severity() {
let commits = vec![
CommitInfo {
hash: "a1".to_string(),
subject: "fix: patch fix".to_string(),
category: CommitCategory::Fix,
},
CommitInfo {
hash: "b2".to_string(),
subject: "feat: add feature".to_string(),
category: CommitCategory::Feature,
},
CommitInfo {
hash: "c3".to_string(),
subject: "refactor!: break API".to_string(),
category: CommitCategory::Breaking,
},
];
assert_eq!(
recommended_bump_from_commits(&commits),
Some(SemverBump::Major)
);
}
#[test]
fn recommended_bump_ignores_docs_and_chore() {
let commits = vec![
CommitInfo {
hash: "a1".to_string(),
subject: "docs: update".to_string(),
category: CommitCategory::Docs,
},
CommitInfo {
hash: "b2".to_string(),
subject: "chore: cleanup".to_string(),
category: CommitCategory::Chore,
},
];
assert_eq!(recommended_bump_from_commits(&commits), None);
}
#[test]
fn recommended_bump_from_fix_and_other_is_patch() {
let commits = vec![
CommitInfo {
hash: "a1".to_string(),
subject: "random commit".to_string(),
category: CommitCategory::Other,
},
CommitInfo {
hash: "b2".to_string(),
subject: "fix: bug".to_string(),
category: CommitCategory::Fix,
},
];
assert_eq!(
recommended_bump_from_commits(&commits),
Some(SemverBump::Patch)
);
}
}