git_claw/core/
conventional.rs1use crate::core::semver::BumpType;
4
5pub const STANDARD_TYPES: &[&str] = &[
7 "feat", "fix", "perf", "refactor", "chore", "docs", "style", "test", "build", "ci",
8];
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ConventionalCommit {
13 pub commit_type: String,
15 pub scope: Option<String>,
17 pub is_breaking: bool,
19 pub description: String,
21}
22
23pub fn parse_conventional_commit(message: &str) -> Option<ConventionalCommit> {
48 let first_line = message.lines().next()?.trim();
49 let (header_prefix, description) = first_line.split_once(": ")?;
50 let description = description.trim();
51 if description.is_empty() {
52 return None;
53 }
54
55 let (prefix_without_bang, bang) = if let Some(stripped) = header_prefix.strip_suffix('!') {
56 (stripped, true)
57 } else {
58 (header_prefix, false)
59 };
60
61 let (commit_type, scope) = if let Some((t, rest)) = prefix_without_bang.split_once('(') {
62 let scope_val = rest.strip_suffix(')')?;
63 (t.trim(), Some(scope_val.trim().to_string()))
64 } else {
65 (prefix_without_bang.trim(), None)
66 };
67
68 if !STANDARD_TYPES.contains(&commit_type) {
69 return None;
70 }
71
72 let is_breaking = bang
73 || message.contains("BREAKING CHANGE:")
74 || message.contains("BREAKING-CHANGE:")
75 || message.contains("BREAKING CHANGE ");
76
77 Some(ConventionalCommit {
78 commit_type: commit_type.to_string(),
79 scope,
80 is_breaking,
81 description: description.to_string(),
82 })
83}
84
85pub fn determine_bump(commits: &[ConventionalCommit]) -> BumpType {
113 if commits.iter().any(|c| c.is_breaking) {
114 BumpType::Major
115 } else if commits.iter().any(|c| c.commit_type == "feat") {
116 BumpType::Minor
117 } else {
118 BumpType::Patch
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn test_parse_simple_feat() {
128 let commit = parse_conventional_commit("feat: add search endpoint").unwrap();
129 assert_eq!(commit.commit_type, "feat");
130 assert_eq!(commit.scope, None);
131 assert!(!commit.is_breaking);
132 assert_eq!(commit.description, "add search endpoint");
133 }
134
135 #[test]
136 fn test_parse_scoped_fix() {
137 let commit = parse_conventional_commit("fix(router): resolve redirect loop").unwrap();
138 assert_eq!(commit.commit_type, "fix");
139 assert_eq!(commit.scope, Some("router".to_string()));
140 assert!(!commit.is_breaking);
141 assert_eq!(commit.description, "resolve redirect loop");
142 }
143
144 #[test]
145 fn test_parse_breaking_with_bang() {
146 let commit = parse_conventional_commit("feat!: drop v1 API support").unwrap();
147 assert_eq!(commit.commit_type, "feat");
148 assert_eq!(commit.scope, None);
149 assert!(commit.is_breaking);
150 assert_eq!(commit.description, "drop v1 API support");
151
152 let commit_scoped = parse_conventional_commit("refactor(db)!: change schema").unwrap();
153 assert_eq!(commit_scoped.commit_type, "refactor");
154 assert_eq!(commit_scoped.scope, Some("db".to_string()));
155 assert!(commit_scoped.is_breaking);
156 }
157
158 #[test]
159 fn test_parse_breaking_in_body() {
160 let msg = "fix: update auth flow\n\nBREAKING CHANGE: credentials format changed";
161 let commit = parse_conventional_commit(msg).unwrap();
162 assert_eq!(commit.commit_type, "fix");
163 assert!(commit.is_breaking);
164
165 let msg_hyphen = "chore: update deps\n\nBREAKING-CHANGE: requires node 20";
166 let commit_hyphen = parse_conventional_commit(msg_hyphen).unwrap();
167 assert!(commit_hyphen.is_breaking);
168 }
169
170 #[test]
171 fn test_parse_non_conventional() {
172 assert!(parse_conventional_commit("Initial commit").is_none());
173 assert!(parse_conventional_commit("Merge branch 'main'").is_none());
174 assert!(parse_conventional_commit("customtype: something").is_none());
175 assert!(parse_conventional_commit("feat: ").is_none());
176 }
177
178 #[test]
179 fn test_determine_bump() {
180 let fix = ConventionalCommit {
181 commit_type: "fix".to_string(),
182 scope: None,
183 is_breaking: false,
184 description: "bug fix".to_string(),
185 };
186 let feat = ConventionalCommit {
187 commit_type: "feat".to_string(),
188 scope: None,
189 is_breaking: false,
190 description: "new feature".to_string(),
191 };
192 let breaking = ConventionalCommit {
193 commit_type: "fix".to_string(),
194 scope: None,
195 is_breaking: true,
196 description: "breaking fix".to_string(),
197 };
198
199 assert_eq!(determine_bump(&[]), BumpType::Patch);
200 assert_eq!(determine_bump(std::slice::from_ref(&fix)), BumpType::Patch);
201 assert_eq!(
202 determine_bump(&[fix.clone(), feat.clone()]),
203 BumpType::Minor
204 );
205 assert_eq!(determine_bump(&[fix, feat, breaking]), BumpType::Major);
206 }
207}