cuenv_release/
conventional.rs

1//! Conventional commit parsing and analysis.
2//!
3//! This module uses the `git-conventional` crate to parse commit messages
4//! following the Conventional Commits specification, and `gix` for git
5//! repository access.
6
7use crate::changeset::BumpType;
8use crate::error::{Error, Result};
9use gix::bstr::ByteSlice;
10use std::path::Path;
11
12/// A parsed conventional commit with version bump information.
13#[derive(Debug, Clone)]
14pub struct ConventionalCommit {
15    /// The commit type (feat, fix, chore, etc.)
16    pub commit_type: String,
17    /// Optional scope
18    pub scope: Option<String>,
19    /// Whether this is a breaking change
20    pub breaking: bool,
21    /// The commit description (first line after type)
22    pub description: String,
23    /// Optional commit body
24    pub body: Option<String>,
25    /// The full commit hash
26    pub hash: String,
27}
28
29impl ConventionalCommit {
30    /// Determine the bump type for this commit.
31    #[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
45/// Parser for conventional commits from a git repository.
46pub struct CommitParser;
47
48impl CommitParser {
49    /// Parse all conventional commits since the given tag.
50    ///
51    /// If `since_tag` is `None`, parses all commits.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the repository cannot be opened or commits cannot be read.
56    #[allow(clippy::default_trait_access)] // gix API requires Default::default() for sorting config
57    #[allow(clippy::redundant_closure_for_method_calls)] // closures needed for type conversion from git_conventional types
58    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        // Get HEAD reference
66        let head = repo
67            .head_id()
68            .map_err(|e| Error::git(format!("Failed to get HEAD: {e}")))?;
69
70        // Set up revision walk
71        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        // If we have a since_tag, find it and use as boundary
80        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                // Collect available tags for suggestions
85                let available_tags = list_tags(&repo);
86                let suggestion = if available_tags.is_empty() {
87                    String::new()
88                } else {
89                    // Find similar tags
90                    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            // Stop if we hit the boundary tag
136            if let Some(boundary) = boundary_oid
137                && oid == boundary
138            {
139                break;
140            }
141
142            // Get the commit object
143            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            // Try to parse as conventional commit
151            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    /// Calculate the aggregate bump type from a list of commits.
167    ///
168    /// Returns the highest bump type among all commits.
169    #[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    /// Generate a summary of commits grouped by type.
178    #[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
240/// Find the OID for a given tag name.
241fn find_tag_oid(repo: &gix::Repository, tag_name: &str) -> Option<gix::ObjectId> {
242    // Try various tag formats
243    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
260/// List all tags in the repository.
261fn 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                // Strip "refs/tags/" prefix
270                let tag_name = name.strip_prefix("refs/tags/").unwrap_or(name);
271                tags.push(tag_name.to_string());
272            }
273        }
274    }
275
276    // Sort by version (most recent first) - simple reverse sort works for semver
277    tags.sort();
278    tags.reverse();
279    tags
280}
281
282/// Calculate Levenshtein distance between two strings.
283/// Used for fuzzy matching tag suggestions.
284fn 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}