1#![allow(clippy::too_many_lines)]
9
10use crate::changeset::BumpType;
11use crate::config::TagType;
12use crate::error::{Error, Result};
13use gix::bstr::ByteSlice;
14use semver::Version as SemverVersion;
15use std::cmp::Ordering;
16use std::path::Path;
17
18#[derive(Debug, Clone)]
20pub struct ConventionalCommit {
21 pub commit_type: String,
23 pub scope: Option<String>,
25 pub breaking: bool,
27 pub description: String,
29 pub body: Option<String>,
31 pub hash: String,
33}
34
35impl ConventionalCommit {
36 #[must_use]
38 pub fn bump_type(&self) -> BumpType {
39 if self.breaking {
40 return BumpType::Major;
41 }
42
43 match self.commit_type.as_str() {
44 "feat" => BumpType::Minor,
45 "fix" | "perf" => BumpType::Patch,
46 _ => BumpType::None,
47 }
48 }
49}
50
51pub struct CommitParser;
53
54impl CommitParser {
55 #[allow(clippy::default_trait_access)] #[allow(clippy::redundant_closure_for_method_calls)] pub fn parse_since_tag(
66 root: &Path,
67 since_tag: Option<&str>,
68 tag_prefix: &str,
69 tag_type: TagType,
70 ) -> Result<Vec<ConventionalCommit>> {
71 let repo =
73 gix::open(root).map_err(|e| Error::git(format!("Failed to open repository: {e}")))?;
74
75 let head = repo
77 .head_id()
78 .map_err(|e| Error::git(format!("Failed to get HEAD: {e}")))?;
79
80 let mut walk = repo
82 .rev_walk([head])
83 .sorting(gix::revision::walk::Sorting::ByCommitTime(
84 Default::default(),
85 ))
86 .all()
87 .map_err(|e| Error::git(format!("Failed to create rev walk: {e}")))?;
88
89 let boundary_oid = if let Some(tag) = since_tag {
91 if let Some(oid) = find_tag_oid(&repo, tag) {
92 Some(oid)
93 } else {
94 let available_tags = list_tags_for_config(&repo, tag_prefix, tag_type);
96 let suggestion = if available_tags.is_empty() {
97 String::new()
98 } else {
99 let similar: Vec<_> = available_tags
101 .iter()
102 .filter(|t| {
103 t.contains(tag)
104 || tag.contains(t.as_str())
105 || levenshtein_distance(t, tag) <= 3
106 })
107 .take(3)
108 .collect();
109
110 if similar.is_empty() {
111 format!(
112 ". Available tags: {}",
113 available_tags
114 .iter()
115 .take(5)
116 .cloned()
117 .collect::<Vec<_>>()
118 .join(", ")
119 )
120 } else {
121 format!(
122 ". Did you mean: {}?",
123 similar
124 .iter()
125 .map(|s| s.as_str())
126 .collect::<Vec<_>>()
127 .join(", ")
128 )
129 }
130 };
131 return Err(Error::git(format!(
132 "Tag '{tag}' not found in repository{suggestion}"
133 )));
134 }
135 } else {
136 let tags = list_tags_for_config(&repo, tag_prefix, tag_type);
138 tags.first().and_then(|tag| find_tag_oid(&repo, tag))
139 };
140
141 let mut commits = Vec::new();
142
143 for info in walk.by_ref() {
144 let info = info.map_err(|e| Error::git(format!("Failed to walk commits: {e}")))?;
145 let oid = info.id;
146
147 if let Some(boundary) = boundary_oid
149 && oid == boundary
150 {
151 break;
152 }
153
154 let commit = repo
156 .find_commit(oid)
157 .map_err(|e| Error::git(format!("Failed to find commit: {e}")))?;
158
159 let message = commit.message_raw_sloppy().to_string();
160 let hash = oid.to_string();
161
162 if let Ok(parsed) = git_conventional::Commit::parse(&message) {
164 commits.push(ConventionalCommit {
165 commit_type: parsed.type_().to_string(),
166 scope: parsed.scope().map(|s| s.to_string()),
167 breaking: parsed.breaking(),
168 description: parsed.description().to_string(),
169 body: parsed.body().map(|b| b.to_string()),
170 hash,
171 });
172 }
173 }
174
175 Ok(commits)
176 }
177
178 #[must_use]
182 pub fn aggregate_bump(commits: &[ConventionalCommit]) -> BumpType {
183 commits
184 .iter()
185 .map(ConventionalCommit::bump_type)
186 .fold(BumpType::None, std::cmp::max)
187 }
188
189 #[must_use]
191 pub fn summarize(commits: &[ConventionalCommit]) -> String {
192 let mut features = Vec::new();
193 let mut fixes = Vec::new();
194 let mut breaking = Vec::new();
195
196 for commit in commits {
197 let desc = commit.scope.as_ref().map_or_else(
198 || commit.description.clone(),
199 |scope| format!("**{scope}**: {}", commit.description),
200 );
201
202 if commit.breaking {
203 breaking.push(desc.clone());
204 }
205
206 match commit.commit_type.as_str() {
207 "feat" => features.push(desc),
208 "fix" | "perf" => fixes.push(desc),
209 _ => {}
211 }
212 }
213
214 let mut summary = String::new();
215
216 if !breaking.is_empty() {
217 summary.push_str("### Breaking Changes\n\n");
218 for item in &breaking {
219 summary.push_str("- ");
220 summary.push_str(item);
221 summary.push('\n');
222 }
223 summary.push('\n');
224 }
225
226 if !features.is_empty() {
227 summary.push_str("### Features\n\n");
228 for item in &features {
229 summary.push_str("- ");
230 summary.push_str(item);
231 summary.push('\n');
232 }
233 summary.push('\n');
234 }
235
236 if !fixes.is_empty() {
237 summary.push_str("### Bug Fixes\n\n");
238 for item in &fixes {
239 summary.push_str("- ");
240 summary.push_str(item);
241 summary.push('\n');
242 }
243 summary.push('\n');
244 }
245
246 summary
247 }
248}
249
250fn find_tag_oid(repo: &gix::Repository, tag_name: &str) -> Option<gix::ObjectId> {
252 let tag_refs = [
254 format!("refs/tags/{tag_name}"),
255 format!("refs/tags/v{tag_name}"),
256 tag_name.to_string(),
257 ];
258
259 for tag_ref in &tag_refs {
260 if let Ok(reference) = repo.find_reference(tag_ref.as_str())
261 && let Ok(id) = reference.into_fully_peeled_id()
262 {
263 return Some(id.detach());
264 }
265 }
266
267 None
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272enum ComparableVersion {
273 Semver(SemverVersion),
274 Calver(Vec<u32>), }
276
277impl Ord for ComparableVersion {
278 fn cmp(&self, other: &Self) -> Ordering {
279 match (self, other) {
280 (Self::Semver(a), Self::Semver(b)) => a.cmp(b),
281 (Self::Calver(a), Self::Calver(b)) => a.cmp(b),
282 _ => Ordering::Equal,
284 }
285 }
286}
287
288impl PartialOrd for ComparableVersion {
289 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
290 Some(self.cmp(other))
291 }
292}
293
294fn parse_calver(s: &str) -> Option<Vec<u32>> {
296 let parts: std::result::Result<Vec<u32>, _> = s.split('.').map(str::parse).collect();
297 let parts = parts.ok()?;
298 if parts.len() >= 2 { Some(parts) } else { None }
300}
301
302fn extract_version(tag: &str, prefix: &str, tag_type: TagType) -> Option<ComparableVersion> {
304 let version_str = tag.strip_prefix(prefix)?;
305 match tag_type {
306 TagType::Semver => SemverVersion::parse(version_str)
307 .ok()
308 .map(ComparableVersion::Semver),
309 TagType::Calver => parse_calver(version_str).map(ComparableVersion::Calver),
310 }
311}
312
313fn list_tags_for_config(repo: &gix::Repository, prefix: &str, tag_type: TagType) -> Vec<String> {
315 let mut tags_with_versions: Vec<(String, ComparableVersion)> = Vec::new();
316
317 if let Ok(refs) = repo.references()
318 && let Ok(tag_refs) = refs.tags()
319 {
320 for tag_ref in tag_refs.flatten() {
321 if let Ok(name) = tag_ref.name().as_bstr().to_str() {
322 let tag_name = name.strip_prefix("refs/tags/").unwrap_or(name);
324
325 if let Some(version) = extract_version(tag_name, prefix, tag_type) {
326 tags_with_versions.push((tag_name.to_string(), version));
327 }
328 }
329 }
330 }
331
332 tags_with_versions.sort_by(|a, b| b.1.cmp(&a.1));
334 tags_with_versions
335 .into_iter()
336 .map(|(name, _)| name)
337 .collect()
338}
339
340fn levenshtein_distance(a: &str, b: &str) -> usize {
343 let a_chars: Vec<char> = a.chars().collect();
344 let b_chars: Vec<char> = b.chars().collect();
345 let a_len = a_chars.len();
346 let b_len = b_chars.len();
347
348 if a_len == 0 {
349 return b_len;
350 }
351 if b_len == 0 {
352 return a_len;
353 }
354
355 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
356
357 for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
358 row[0] = i;
359 }
360 for (j, val) in matrix[0].iter_mut().enumerate() {
361 *val = j;
362 }
363
364 for i in 1..=a_len {
365 for j in 1..=b_len {
366 let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
367 matrix[i][j] = std::cmp::min(
368 std::cmp::min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1),
369 matrix[i - 1][j - 1] + cost,
370 );
371 }
372 }
373
374 matrix[a_len][b_len]
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn test_bump_type_feat() {
383 let commit = ConventionalCommit {
384 commit_type: "feat".to_string(),
385 scope: None,
386 breaking: false,
387 description: "add feature".to_string(),
388 body: None,
389 hash: "abc123".to_string(),
390 };
391 assert_eq!(commit.bump_type(), BumpType::Minor);
392 }
393
394 #[test]
395 fn test_bump_type_fix() {
396 let commit = ConventionalCommit {
397 commit_type: "fix".to_string(),
398 scope: None,
399 breaking: false,
400 description: "fix bug".to_string(),
401 body: None,
402 hash: "abc123".to_string(),
403 };
404 assert_eq!(commit.bump_type(), BumpType::Patch);
405 }
406
407 #[test]
408 fn test_bump_type_breaking() {
409 let commit = ConventionalCommit {
410 commit_type: "feat".to_string(),
411 scope: None,
412 breaking: true,
413 description: "breaking change".to_string(),
414 body: None,
415 hash: "abc123".to_string(),
416 };
417 assert_eq!(commit.bump_type(), BumpType::Major);
418 }
419
420 #[test]
421 fn test_bump_type_chore() {
422 let commit = ConventionalCommit {
423 commit_type: "chore".to_string(),
424 scope: None,
425 breaking: false,
426 description: "update deps".to_string(),
427 body: None,
428 hash: "abc123".to_string(),
429 };
430 assert_eq!(commit.bump_type(), BumpType::None);
431 }
432
433 #[test]
434 fn test_aggregate_bump() {
435 let commits = vec![
436 ConventionalCommit {
437 commit_type: "fix".to_string(),
438 scope: None,
439 breaking: false,
440 description: "fix".to_string(),
441 body: None,
442 hash: "1".to_string(),
443 },
444 ConventionalCommit {
445 commit_type: "feat".to_string(),
446 scope: None,
447 breaking: false,
448 description: "feat".to_string(),
449 body: None,
450 hash: "2".to_string(),
451 },
452 ];
453 assert_eq!(CommitParser::aggregate_bump(&commits), BumpType::Minor);
454 }
455
456 #[test]
457 fn test_summarize() {
458 let commits = vec![
459 ConventionalCommit {
460 commit_type: "feat".to_string(),
461 scope: Some("api".to_string()),
462 breaking: false,
463 description: "add endpoint".to_string(),
464 body: None,
465 hash: "1".to_string(),
466 },
467 ConventionalCommit {
468 commit_type: "fix".to_string(),
469 scope: None,
470 breaking: false,
471 description: "fix crash".to_string(),
472 body: None,
473 hash: "2".to_string(),
474 },
475 ];
476
477 let summary = CommitParser::summarize(&commits);
478 assert!(summary.contains("### Features"));
479 assert!(summary.contains("**api**: add endpoint"));
480 assert!(summary.contains("### Bug Fixes"));
481 assert!(summary.contains("fix crash"));
482 }
483
484 #[test]
485 fn test_levenshtein_distance_identical() {
486 assert_eq!(levenshtein_distance("hello", "hello"), 0);
487 }
488
489 #[test]
490 fn test_levenshtein_distance_single_edit() {
491 assert_eq!(levenshtein_distance("hello", "hallo"), 1);
492 assert_eq!(levenshtein_distance("v1.0.0", "v1.0.1"), 1);
493 }
494
495 #[test]
496 fn test_levenshtein_distance_prefix() {
497 assert_eq!(levenshtein_distance("v1.0.0", "1.0.0"), 1);
498 }
499
500 #[test]
501 fn test_levenshtein_distance_empty() {
502 assert_eq!(levenshtein_distance("", "hello"), 5);
503 assert_eq!(levenshtein_distance("hello", ""), 5);
504 assert_eq!(levenshtein_distance("", ""), 0);
505 }
506}