1use crate::changeset::BumpType;
8use crate::error::{Error, Result};
9use gix::bstr::ByteSlice;
10use std::path::Path;
11
12#[derive(Debug, Clone)]
14pub struct ConventionalCommit {
15 pub commit_type: String,
17 pub scope: Option<String>,
19 pub breaking: bool,
21 pub description: String,
23 pub body: Option<String>,
25 pub hash: String,
27}
28
29impl ConventionalCommit {
30 #[must_use]
32 pub fn bump_type(&self) -> BumpType {
33 if self.breaking {
34 return BumpType::Major;
35 }
36
37 match self.commit_type.as_str() {
38 "feat" => BumpType::Minor,
39 "fix" | "perf" => BumpType::Patch,
40 _ => BumpType::None,
41 }
42 }
43}
44
45pub struct CommitParser;
47
48impl CommitParser {
49 #[allow(clippy::default_trait_access)] #[allow(clippy::redundant_closure_for_method_calls)] pub fn parse_since_tag(
59 root: &Path,
60 since_tag: Option<&str>,
61 ) -> Result<Vec<ConventionalCommit>> {
62 let repo =
63 gix::open(root).map_err(|e| Error::git(format!("Failed to open repository: {e}")))?;
64
65 let head = repo
67 .head_id()
68 .map_err(|e| Error::git(format!("Failed to get HEAD: {e}")))?;
69
70 let mut walk = repo
72 .rev_walk([head])
73 .sorting(gix::revision::walk::Sorting::ByCommitTime(
74 Default::default(),
75 ))
76 .all()
77 .map_err(|e| Error::git(format!("Failed to create rev walk: {e}")))?;
78
79 let boundary_oid = if let Some(tag) = since_tag {
81 if let Some(oid) = find_tag_oid(&repo, tag) {
82 Some(oid)
83 } else {
84 let available_tags = list_tags(&repo);
86 let suggestion = if available_tags.is_empty() {
87 String::new()
88 } else {
89 let similar: Vec<_> = available_tags
91 .iter()
92 .filter(|t| {
93 t.contains(tag)
94 || tag.contains(t.as_str())
95 || levenshtein_distance(t, tag) <= 3
96 })
97 .take(3)
98 .collect();
99
100 if similar.is_empty() {
101 format!(
102 ". Available tags: {}",
103 available_tags
104 .iter()
105 .take(5)
106 .cloned()
107 .collect::<Vec<_>>()
108 .join(", ")
109 )
110 } else {
111 format!(
112 ". Did you mean: {}?",
113 similar
114 .iter()
115 .map(|s| s.as_str())
116 .collect::<Vec<_>>()
117 .join(", ")
118 )
119 }
120 };
121 return Err(Error::git(format!(
122 "Tag '{tag}' not found in repository{suggestion}"
123 )));
124 }
125 } else {
126 None
127 };
128
129 let mut commits = Vec::new();
130
131 for info in walk.by_ref() {
132 let info = info.map_err(|e| Error::git(format!("Failed to walk commits: {e}")))?;
133 let oid = info.id;
134
135 if let Some(boundary) = boundary_oid
137 && oid == boundary
138 {
139 break;
140 }
141
142 let commit = repo
144 .find_commit(oid)
145 .map_err(|e| Error::git(format!("Failed to find commit: {e}")))?;
146
147 let message = commit.message_raw_sloppy().to_string();
148 let hash = oid.to_string();
149
150 if let Ok(parsed) = git_conventional::Commit::parse(&message) {
152 commits.push(ConventionalCommit {
153 commit_type: parsed.type_().to_string(),
154 scope: parsed.scope().map(|s| s.to_string()),
155 breaking: parsed.breaking(),
156 description: parsed.description().to_string(),
157 body: parsed.body().map(|b| b.to_string()),
158 hash,
159 });
160 }
161 }
162
163 Ok(commits)
164 }
165
166 #[must_use]
170 pub fn aggregate_bump(commits: &[ConventionalCommit]) -> BumpType {
171 commits
172 .iter()
173 .map(ConventionalCommit::bump_type)
174 .fold(BumpType::None, std::cmp::max)
175 }
176
177 #[must_use]
179 pub fn summarize(commits: &[ConventionalCommit]) -> String {
180 let mut features = Vec::new();
181 let mut fixes = Vec::new();
182 let mut breaking = Vec::new();
183 let mut other = Vec::new();
184
185 for commit in commits {
186 let desc = if let Some(ref scope) = commit.scope {
187 format!("**{}**: {}", scope, commit.description)
188 } else {
189 commit.description.clone()
190 };
191
192 if commit.breaking {
193 breaking.push(desc.clone());
194 }
195
196 match commit.commit_type.as_str() {
197 "feat" => features.push(desc),
198 "fix" | "perf" => fixes.push(desc),
199 "chore" | "docs" | "style" | "refactor" | "test" | "ci" => other.push(desc),
200 _ => {}
201 }
202 }
203
204 let mut summary = String::new();
205
206 if !breaking.is_empty() {
207 summary.push_str("### Breaking Changes\n\n");
208 for item in &breaking {
209 summary.push_str("- ");
210 summary.push_str(item);
211 summary.push('\n');
212 }
213 summary.push('\n');
214 }
215
216 if !features.is_empty() {
217 summary.push_str("### Features\n\n");
218 for item in &features {
219 summary.push_str("- ");
220 summary.push_str(item);
221 summary.push('\n');
222 }
223 summary.push('\n');
224 }
225
226 if !fixes.is_empty() {
227 summary.push_str("### Bug Fixes\n\n");
228 for item in &fixes {
229 summary.push_str("- ");
230 summary.push_str(item);
231 summary.push('\n');
232 }
233 summary.push('\n');
234 }
235
236 summary
237 }
238}
239
240fn find_tag_oid(repo: &gix::Repository, tag_name: &str) -> Option<gix::ObjectId> {
242 let tag_refs = [
244 format!("refs/tags/{tag_name}"),
245 format!("refs/tags/v{tag_name}"),
246 tag_name.to_string(),
247 ];
248
249 for tag_ref in &tag_refs {
250 if let Ok(reference) = repo.find_reference(tag_ref.as_str())
251 && let Ok(id) = reference.into_fully_peeled_id()
252 {
253 return Some(id.detach());
254 }
255 }
256
257 None
258}
259
260fn list_tags(repo: &gix::Repository) -> Vec<String> {
262 let mut tags = Vec::new();
263
264 if let Ok(refs) = repo.references()
265 && let Ok(tag_refs) = refs.tags()
266 {
267 for tag_ref in tag_refs.flatten() {
268 if let Ok(name) = tag_ref.name().as_bstr().to_str() {
269 let tag_name = name.strip_prefix("refs/tags/").unwrap_or(name);
271 tags.push(tag_name.to_string());
272 }
273 }
274 }
275
276 tags.sort();
278 tags.reverse();
279 tags
280}
281
282fn levenshtein_distance(a: &str, b: &str) -> usize {
285 let a_chars: Vec<char> = a.chars().collect();
286 let b_chars: Vec<char> = b.chars().collect();
287 let a_len = a_chars.len();
288 let b_len = b_chars.len();
289
290 if a_len == 0 {
291 return b_len;
292 }
293 if b_len == 0 {
294 return a_len;
295 }
296
297 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
298
299 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
300 row[0] = i;
301 }
302 for (j, val) in matrix[0].iter_mut().enumerate() {
303 *val = j;
304 }
305
306 for i in 1..=a_len {
307 for j in 1..=b_len {
308 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
309 matrix[i][j] = std::cmp::min(
310 std::cmp::min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1),
311 matrix[i - 1][j - 1] + cost,
312 );
313 }
314 }
315
316 matrix[a_len][b_len]
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_bump_type_feat() {
325 let commit = ConventionalCommit {
326 commit_type: "feat".to_string(),
327 scope: None,
328 breaking: false,
329 description: "add feature".to_string(),
330 body: None,
331 hash: "abc123".to_string(),
332 };
333 assert_eq!(commit.bump_type(), BumpType::Minor);
334 }
335
336 #[test]
337 fn test_bump_type_fix() {
338 let commit = ConventionalCommit {
339 commit_type: "fix".to_string(),
340 scope: None,
341 breaking: false,
342 description: "fix bug".to_string(),
343 body: None,
344 hash: "abc123".to_string(),
345 };
346 assert_eq!(commit.bump_type(), BumpType::Patch);
347 }
348
349 #[test]
350 fn test_bump_type_breaking() {
351 let commit = ConventionalCommit {
352 commit_type: "feat".to_string(),
353 scope: None,
354 breaking: true,
355 description: "breaking change".to_string(),
356 body: None,
357 hash: "abc123".to_string(),
358 };
359 assert_eq!(commit.bump_type(), BumpType::Major);
360 }
361
362 #[test]
363 fn test_bump_type_chore() {
364 let commit = ConventionalCommit {
365 commit_type: "chore".to_string(),
366 scope: None,
367 breaking: false,
368 description: "update deps".to_string(),
369 body: None,
370 hash: "abc123".to_string(),
371 };
372 assert_eq!(commit.bump_type(), BumpType::None);
373 }
374
375 #[test]
376 fn test_aggregate_bump() {
377 let commits = vec![
378 ConventionalCommit {
379 commit_type: "fix".to_string(),
380 scope: None,
381 breaking: false,
382 description: "fix".to_string(),
383 body: None,
384 hash: "1".to_string(),
385 },
386 ConventionalCommit {
387 commit_type: "feat".to_string(),
388 scope: None,
389 breaking: false,
390 description: "feat".to_string(),
391 body: None,
392 hash: "2".to_string(),
393 },
394 ];
395 assert_eq!(CommitParser::aggregate_bump(&commits), BumpType::Minor);
396 }
397
398 #[test]
399 fn test_summarize() {
400 let commits = vec![
401 ConventionalCommit {
402 commit_type: "feat".to_string(),
403 scope: Some("api".to_string()),
404 breaking: false,
405 description: "add endpoint".to_string(),
406 body: None,
407 hash: "1".to_string(),
408 },
409 ConventionalCommit {
410 commit_type: "fix".to_string(),
411 scope: None,
412 breaking: false,
413 description: "fix crash".to_string(),
414 body: None,
415 hash: "2".to_string(),
416 },
417 ];
418
419 let summary = CommitParser::summarize(&commits);
420 assert!(summary.contains("### Features"));
421 assert!(summary.contains("**api**: add endpoint"));
422 assert!(summary.contains("### Bug Fixes"));
423 assert!(summary.contains("fix crash"));
424 }
425
426 #[test]
427 fn test_levenshtein_distance_identical() {
428 assert_eq!(levenshtein_distance("hello", "hello"), 0);
429 }
430
431 #[test]
432 fn test_levenshtein_distance_single_edit() {
433 assert_eq!(levenshtein_distance("hello", "hallo"), 1);
434 assert_eq!(levenshtein_distance("v1.0.0", "v1.0.1"), 1);
435 }
436
437 #[test]
438 fn test_levenshtein_distance_prefix() {
439 assert_eq!(levenshtein_distance("v1.0.0", "1.0.0"), 1);
440 }
441
442 #[test]
443 fn test_levenshtein_distance_empty() {
444 assert_eq!(levenshtein_distance("", "hello"), 5);
445 assert_eq!(levenshtein_distance("hello", ""), 5);
446 assert_eq!(levenshtein_distance("", ""), 0);
447 }
448}