Skip to main content

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
7// Git repository traversal and commit parsing involves complex iteration
8#![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/// A parsed conventional commit with version bump information.
19#[derive(Debug, Clone)]
20pub struct ConventionalCommit {
21    /// The commit type (feat, fix, chore, etc.)
22    pub commit_type: String,
23    /// Optional scope
24    pub scope: Option<String>,
25    /// Whether this is a breaking change
26    pub breaking: bool,
27    /// The commit description (first line after type)
28    pub description: String,
29    /// Optional commit body
30    pub body: Option<String>,
31    /// The full commit hash
32    pub hash: String,
33}
34
35impl ConventionalCommit {
36    /// Determine the bump type for this commit.
37    #[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
51/// Parser for conventional commits from a git repository.
52pub struct CommitParser;
53
54impl CommitParser {
55    /// Parse all conventional commits since the given tag.
56    ///
57    /// If `since_tag` is `None`, auto-detects the latest tag matching the
58    /// configured `tag_prefix` and `tag_type`.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the repository cannot be opened or commits cannot be read.
63    #[allow(clippy::default_trait_access)] // gix API requires Default::default() for sorting config
64    #[allow(clippy::redundant_closure_for_method_calls)] // closures needed for type conversion from git_conventional types
65    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        // Use gix::open which handles worktree discovery internally
72        let repo =
73            gix::open(root).map_err(|e| Error::git(format!("Failed to open repository: {e}")))?;
74
75        // Get HEAD reference
76        let head = repo
77            .head_id()
78            .map_err(|e| Error::git(format!("Failed to get HEAD: {e}")))?;
79
80        // Set up revision walk
81        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        // If we have a since_tag, find it and use as boundary
90        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                // Collect available tags for suggestions
95                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                    // Find similar tags
100                    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            // Auto-detect latest tag matching the configured prefix and type
137            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            // Stop if we hit the boundary tag
148            if let Some(boundary) = boundary_oid
149                && oid == boundary
150            {
151                break;
152            }
153
154            // Get the commit object
155            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            // Try to parse as conventional commit
163            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    /// Calculate the aggregate bump type from a list of commits.
179    ///
180    /// Returns the highest bump type among all commits.
181    #[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    /// Generate a summary of commits grouped by type.
190    #[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                // chore, docs, style, refactor, test, ci - not included in release summaries
210                _ => {}
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
250/// Find the OID for a given tag name.
251fn find_tag_oid(repo: &gix::Repository, tag_name: &str) -> Option<gix::ObjectId> {
252    // Try various tag formats
253    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/// A comparable version that can be either semver or calver.
271#[derive(Debug, Clone, PartialEq, Eq)]
272enum ComparableVersion {
273    Semver(SemverVersion),
274    Calver(Vec<u32>), // e.g., [2024, 12, 23] or [2024, 12]
275}
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            // Different types shouldn't happen in practice
283            _ => 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
294/// Parse a calver version string (e.g., "2024.12.23" or "24.04").
295fn 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    // CalVer needs at least 2 parts (year.month or similar)
299    if parts.len() >= 2 { Some(parts) } else { None }
300}
301
302/// Extract version from a tag if it matches the prefix and tag type.
303fn 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
313/// List all tags matching the prefix and type, sorted by version (newest first).
314fn 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                // Strip "refs/tags/" prefix
323                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    // Sort by version (most recent first)
333    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
340/// Calculate Levenshtein distance between two strings.
341/// Used for fuzzy matching tag suggestions.
342fn 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}