Skip to main content

governor_core/domain/
commit.rs

1//! Commit domain entity
2//!
3//! This module provides structures for working with git commits and parsing
4//! [Conventional Commits](https://www.conventionalcommits.org/) format.
5//!
6//! # Conventional Commits Format
7//!
8//! The basic format is: `<type>[optional scope]: <description>`
9//!
10//! Common types:
11//! - `feat`: New feature
12//! - `fix`: Bug fix
13//! - `docs`: Documentation changes
14//! - `style`: Code style changes
15//! - `refactor`: Code refactoring
16//! - `perf`: Performance improvements
17//! - `test`: Adding tests
18//! - `build`: Build system changes
19//! - `ci`: CI configuration changes
20//! - `chore`: Maintenance tasks
21//!
22//! # Examples
23//!
24//! ```
25//! use governor_core::domain::commit::{Commit, CommitType};
26//!
27//! let commit = Commit::new(
28//!     "abc123def".to_string(),
29//!     "feat(api): add user authentication".to_string(),
30//!     "Alice".to_string(),
31//!     "alice@example.com".to_string(),
32//!     chrono::Utc::now(),
33//! );
34//!
35//! assert_eq!(commit.commit_type, Some(CommitType::Feat));
36//! assert_eq!(commit.scope, Some("api".to_string()));
37//! ```
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42/// A git commit
43///
44/// Represents a git commit with parsed conventional commit metadata.
45///
46/// # Examples
47///
48/// ```
49/// use governor_core::domain::commit::Commit;
50/// use chrono::Utc;
51///
52/// let commit = Commit::new(
53///     "abc123def".to_string(),
54///     "feat: add new feature".to_string(),
55///     "Alice".to_string(),
56///     "alice@example.com".to_string(),
57///     Utc::now(),
58/// );
59/// ```
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct Commit {
62    /// Full commit hash
63    pub hash: String,
64    /// Short commit hash (7 characters)
65    pub short_hash: String,
66    /// Commit message
67    pub message: String,
68    /// Commit author
69    pub author: String,
70    /// Commit author email
71    pub author_email: String,
72    /// Commit date
73    pub date: DateTime<Utc>,
74    /// Files changed in this commit
75    pub files_changed: Vec<String>,
76    /// Conventional commit type (if parsed)
77    pub commit_type: Option<CommitType>,
78    /// Conventional commit scope (if present)
79    pub scope: Option<String>,
80    /// Whether this is a breaking change
81    pub breaking: bool,
82    /// Commit body
83    pub body: Option<String>,
84}
85
86impl Commit {
87    /// Create a new commit
88    #[must_use]
89    pub fn new(
90        hash: String,
91        message: String,
92        author: String,
93        author_email: String,
94        date: DateTime<Utc>,
95    ) -> Self {
96        let short_hash = hash.chars().take(7).collect();
97        let (commit_type, scope, breaking) = Self::parse_conventional(&message);
98
99        Self {
100            hash,
101            short_hash,
102            message,
103            author,
104            author_email,
105            date,
106            files_changed: Vec::new(),
107            commit_type,
108            scope,
109            breaking,
110            body: None,
111        }
112    }
113
114    /// Parse a conventional commit message
115    fn parse_conventional(message: &str) -> (Option<CommitType>, Option<String>, bool) {
116        let parts: Vec<&str> = message.splitn(2, ':').collect();
117        if parts.len() < 2 {
118            return (None, None, false);
119        }
120
121        let prefix = parts[0].trim();
122
123        // Extract type and scope from prefix like "type(scope)" or "type!"
124        let type_str = prefix.trim_end_matches('!');
125
126        // Parse scope from type(scope) format
127        let scope = type_str.find(')').and_then(|end| {
128            type_str.find('(').and_then(|start| {
129                let scope_str = &type_str[start + 1..end];
130                if scope_str.is_empty() {
131                    None
132                } else {
133                    Some(scope_str.to_string())
134                }
135            })
136        });
137
138        // Get the base type without scope
139        let base_type = type_str
140            .find('(')
141            .map_or(type_str, |start| &type_str[..start]);
142
143        let breaking = prefix.ends_with('!');
144        let commit_type = CommitType::parse_from_str(base_type);
145
146        // Check for BREAKING CHANGE in body
147        let has_breaking_body = message
148            .lines()
149            .skip(1)
150            .any(|line| line.to_uppercase().contains("BREAKING CHANGE"));
151
152        (commit_type, scope, breaking || has_breaking_body)
153    }
154
155    /// Get the commit type
156    #[must_use]
157    pub const fn commit_type(&self) -> Option<CommitType> {
158        self.commit_type
159    }
160
161    /// Check if this is a conventional commit
162    #[must_use]
163    pub const fn is_conventional(&self) -> bool {
164        self.commit_type.is_some()
165    }
166
167    /// Check if this commit affects a specific scope
168    #[must_use]
169    pub fn affects_scope(&self, scope: &str) -> bool {
170        self.scope.as_deref() == Some(scope)
171    }
172
173    /// Check if this commit is of a specific type
174    #[must_use]
175    pub fn is_type(&self, commit_type: CommitType) -> bool {
176        self.commit_type == Some(commit_type)
177    }
178
179    /// Get a shortened message (first line)
180    #[must_use]
181    pub fn short_message(&self) -> &str {
182        self.message.lines().next().unwrap_or(&self.message)
183    }
184
185    /// Get the normalized changelog description without the conventional prefix.
186    #[must_use]
187    pub fn changelog_message(&self) -> &str {
188        self.short_message().split_once(':').map_or_else(
189            || self.short_message(),
190            |(_, description)| description.trim(),
191        )
192    }
193}
194
195/// Conventional commit types
196///
197/// According to the [Conventional Commits](https://www.conventionalcommits.org/) specification.
198///
199/// Each type maps to a specific semantic versioning impact:
200/// - `feat`: Minor version bump (new functionality)
201/// - `fix`: Patch version bump (bug fixes)
202/// - `perf`: Patch version bump (performance improvements)
203/// - `refactor`: Major version bump if breaking, otherwise omitted
204/// - Others: Omitted from changelog
205///
206/// # Examples
207///
208/// ```
209/// use governor_core::domain::commit::CommitType;
210///
211/// assert_eq!(CommitType::parse_from_str("feat"), Some(CommitType::Feat));
212/// assert_eq!(CommitType::parse_from_str("invalid"), None);
213/// assert!(CommitType::Feat.is_feature());
214/// assert!(CommitType::Fix.is_fix());
215/// ```
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum CommitType {
219    /// New feature (minor version bump)
220    Feat,
221    /// Bug fix (patch version bump)
222    Fix,
223    /// Documentation changes (no version bump)
224    Docs,
225    /// Code style changes (no version bump)
226    Style,
227    /// Code refactoring (major if breaking, else no version bump)
228    Refactor,
229    /// Performance improvements (patch version bump)
230    Perf,
231    /// Adding tests (no version bump)
232    Test,
233    /// Build system changes (no version bump)
234    Build,
235    /// CI configuration changes (no version bump)
236    Ci,
237    /// Chore/maintenance (no version bump)
238    Chore,
239    /// Revert a previous commit
240    Revert,
241}
242
243impl CommitType {
244    /// Parse from string
245    #[must_use]
246    pub fn parse_from_str(s: &str) -> Option<Self> {
247        match s.to_lowercase().as_str() {
248            "feat" => Some(Self::Feat),
249            "fix" => Some(Self::Fix),
250            "docs" => Some(Self::Docs),
251            "style" => Some(Self::Style),
252            "refactor" => Some(Self::Refactor),
253            "perf" => Some(Self::Perf),
254            "test" => Some(Self::Test),
255            "build" => Some(Self::Build),
256            "ci" => Some(Self::Ci),
257            "chore" => Some(Self::Chore),
258            "revert" => Some(Self::Revert),
259            _ => None,
260        }
261    }
262
263    /// Check if this type triggers a major version bump
264    #[must_use]
265    pub const fn is_breaking(self) -> bool {
266        matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
267    }
268
269    /// Check if this type triggers a minor version bump
270    #[must_use]
271    pub const fn is_feature(self) -> bool {
272        matches!(self, Self::Feat)
273    }
274
275    /// Check if this type triggers a patch version bump
276    #[must_use]
277    pub const fn is_fix(self) -> bool {
278        matches!(self, Self::Fix | Self::Perf)
279    }
280
281    /// Check if this type should be included in changelog
282    #[must_use]
283    pub const fn is_changeloggable(self) -> bool {
284        matches!(self, Self::Feat | Self::Fix | Self::Perf | Self::Refactor)
285    }
286}
287
288impl std::fmt::Display for CommitType {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        match self {
291            Self::Feat => write!(f, "feat"),
292            Self::Fix => write!(f, "fix"),
293            Self::Docs => write!(f, "docs"),
294            Self::Style => write!(f, "style"),
295            Self::Refactor => write!(f, "refactor"),
296            Self::Perf => write!(f, "perf"),
297            Self::Test => write!(f, "test"),
298            Self::Build => write!(f, "build"),
299            Self::Ci => write!(f, "ci"),
300            Self::Chore => write!(f, "chore"),
301            Self::Revert => write!(f, "revert"),
302        }
303    }
304}
305
306/// A collection of commits
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct CommitHistory {
309    /// All commits in the history
310    pub commits: Vec<Commit>,
311    /// The starting tag/ref
312    pub since: Option<String>,
313    /// The ending ref
314    pub until: Option<String>,
315}
316
317impl CommitHistory {
318    /// Create a new commit history
319    #[must_use]
320    pub const fn new(commits: Vec<Commit>) -> Self {
321        Self {
322            commits,
323            since: None,
324            until: None,
325        }
326    }
327
328    /// Get commits by type
329    #[must_use]
330    pub fn by_type(&self, commit_type: CommitType) -> Vec<&Commit> {
331        self.commits
332            .iter()
333            .filter(|c| c.commit_type == Some(commit_type))
334            .collect()
335    }
336
337    /// Get breaking changes
338    #[must_use]
339    pub fn breaking_changes(&self) -> Vec<&Commit> {
340        self.commits.iter().filter(|c| c.breaking).collect()
341    }
342
343    /// Get features
344    #[must_use]
345    pub fn features(&self) -> Vec<&Commit> {
346        self.commits
347            .iter()
348            .filter(|c| c.commit_type == Some(CommitType::Feat))
349            .collect()
350    }
351
352    /// Get fixes
353    #[must_use]
354    pub fn fixes(&self) -> Vec<&Commit> {
355        self.commits
356            .iter()
357            .filter(|c| c.commit_type == Some(CommitType::Fix))
358            .collect()
359    }
360
361    /// Get changeloggable commits
362    #[must_use]
363    pub fn changeloggable(&self) -> Vec<&Commit> {
364        self.commits
365            .iter()
366            .filter(|c| c.commit_type.is_some_and(CommitType::is_changeloggable))
367            .collect()
368    }
369
370    /// Count commits by type
371    #[must_use]
372    pub fn count_by_type(&self) -> std::collections::HashMap<CommitType, usize> {
373        let mut counts = std::collections::HashMap::new();
374        for commit in &self.commits {
375            if let Some(ct) = commit.commit_type {
376                *counts.entry(ct).or_insert(0) += 1;
377            }
378        }
379        counts
380    }
381
382    /// Check if history is empty
383    #[must_use]
384    pub const fn is_empty(&self) -> bool {
385        self.commits.is_empty()
386    }
387
388    /// Get the number of commits
389    #[must_use]
390    pub const fn len(&self) -> usize {
391        self.commits.len()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_conventional_commit_parsing() {
401        let commit = Commit::new(
402            "abc123".to_string(),
403            "feat: add new feature".to_string(),
404            "Author".to_string(),
405            "author@example.com".to_string(),
406            Utc::now(),
407        );
408        assert_eq!(commit.commit_type, Some(CommitType::Feat));
409        assert!(!commit.breaking);
410        assert!(commit.is_conventional());
411    }
412
413    #[test]
414    fn test_breaking_commit_parsing() {
415        let commit = Commit::new(
416            "abc123".to_string(),
417            "feat!: breaking API change".to_string(),
418            "Author".to_string(),
419            "author@example.com".to_string(),
420            Utc::now(),
421        );
422        assert_eq!(commit.commit_type, Some(CommitType::Feat));
423        assert!(commit.breaking);
424    }
425
426    #[test]
427    fn test_scope_parsing() {
428        let commit = Commit::new(
429            "abc123".to_string(),
430            "feat(api): add new endpoint".to_string(),
431            "Author".to_string(),
432            "author@example.com".to_string(),
433            Utc::now(),
434        );
435        assert_eq!(commit.commit_type, Some(CommitType::Feat));
436        assert_eq!(commit.scope, Some("api".to_string()));
437        assert!(commit.affects_scope("api"));
438    }
439
440    #[test]
441    fn test_non_conventional_commit() {
442        let commit = Commit::new(
443            "abc123".to_string(),
444            "Add some stuff".to_string(),
445            "Author".to_string(),
446            "author@example.com".to_string(),
447            Utc::now(),
448        );
449        assert_eq!(commit.commit_type, None);
450        assert!(!commit.is_conventional());
451    }
452
453    #[test]
454    fn test_commit_history_filtering() {
455        let commits = vec![
456            Commit::new(
457                "1".to_string(),
458                "feat: feature 1".to_string(),
459                "A".to_string(),
460                "a@a.com".to_string(),
461                Utc::now(),
462            ),
463            Commit::new(
464                "2".to_string(),
465                "fix: bug fix".to_string(),
466                "A".to_string(),
467                "a@a.com".to_string(),
468                Utc::now(),
469            ),
470            Commit::new(
471                "3".to_string(),
472                "feat: feature 2".to_string(),
473                "A".to_string(),
474                "a@a.com".to_string(),
475                Utc::now(),
476            ),
477        ];
478        let history = CommitHistory::new(commits);
479        assert_eq!(history.features().len(), 2);
480        assert_eq!(history.fixes().len(), 1);
481        assert_eq!(history.changeloggable().len(), 3);
482    }
483}