Skip to main content

torii_lib/versioning/
conventional.rs

1use anyhow::{Result, anyhow};
2
3#[derive(Debug, Clone, PartialEq)]
4pub enum CommitType {
5    Feat,
6    Fix,
7    Docs,
8    Style,
9    Refactor,
10    Perf,
11    Test,
12    Chore,
13    Build,
14    Ci,
15    Revert,
16    #[allow(dead_code)]
17    Breaking,
18}
19
20impl CommitType {
21    #[allow(dead_code)]
22    pub fn should_create_tag(&self) -> bool {
23        matches!(self, 
24            CommitType::Feat | 
25            CommitType::Fix | 
26            CommitType::Perf | 
27            CommitType::Breaking
28        )
29    }
30}
31
32#[derive(Debug, Clone)]
33#[allow(dead_code)]
34pub struct ConventionalCommit {
35    pub commit_type: CommitType,
36    pub scope: Option<String>,
37    pub description: String,
38    pub body: Option<String>,
39    pub breaking: bool,
40}
41
42impl ConventionalCommit {
43    /// Parse a commit message following Conventional Commits specification
44    /// Format: <type>(<scope>): <description>
45    /// 
46    /// Examples:
47    /// - feat: add user authentication
48    /// - fix(auth): resolve login bug
49    /// - feat!: breaking change
50    /// - BREAKING CHANGE: new API
51    pub fn parse(message: &str) -> Result<Self> {
52        let message = message.trim();
53        
54        // Check for BREAKING CHANGE in body
55        let breaking_in_body = message.contains("BREAKING CHANGE:");
56        
57        // Get first line for parsing
58        let first_line = message.lines().next().unwrap_or("");
59        
60        // Check for breaking change indicator (!)
61        let breaking_indicator = first_line.contains('!');
62        
63        // Split type and rest
64        let parts: Vec<&str> = first_line.splitn(2, ':').collect();
65        if parts.len() != 2 {
66            return Err(anyhow!("Invalid commit format. Expected: <type>(<scope>): <description>"));
67        }
68        
69        let type_part = parts[0].trim();
70        let description = parts[1].trim().to_string();
71        
72        // Parse type and scope
73        let (commit_type_str, scope) = if type_part.contains('(') {
74            let type_scope: Vec<&str> = type_part.splitn(2, '(').collect();
75            let scope_str = type_scope[1].trim_end_matches(')').trim_end_matches('!');
76            (type_scope[0], Some(scope_str.to_string()))
77        } else {
78            (type_part.trim_end_matches('!'), None)
79        };
80        
81        // Parse commit type
82        let commit_type = match commit_type_str.to_lowercase().as_str() {
83            "feat" => CommitType::Feat,
84            "fix" => CommitType::Fix,
85            "docs" => CommitType::Docs,
86            "style" => CommitType::Style,
87            "refactor" => CommitType::Refactor,
88            "perf" => CommitType::Perf,
89            "test" => CommitType::Test,
90            "chore" => CommitType::Chore,
91            "build" => CommitType::Build,
92            "ci" => CommitType::Ci,
93            "revert" => CommitType::Revert,
94            _ => return Err(anyhow!("Unknown commit type: {}", commit_type_str)),
95        };
96        
97        // Get body if exists
98        let body = if message.lines().count() > 1 {
99            Some(message.lines().skip(1).collect::<Vec<_>>().join("\n"))
100        } else {
101            None
102        };
103        
104        // Determine if breaking change
105        let breaking = breaking_indicator || breaking_in_body || commit_type_str == "BREAKING CHANGE";
106        
107        Ok(ConventionalCommit {
108            commit_type,
109            scope,
110            description,
111            body,
112            breaking,
113        })
114    }
115    
116    pub fn is_breaking(&self) -> bool {
117        self.breaking
118    }
119    
120    #[allow(dead_code)]
121    pub fn should_create_tag(&self) -> bool {
122        self.commit_type.should_create_tag() || self.breaking
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    
130    #[test]
131    fn test_parse_feat() {
132        let commit = ConventionalCommit::parse("feat: add user authentication").unwrap();
133        assert_eq!(commit.commit_type, CommitType::Feat);
134        assert_eq!(commit.description, "add user authentication");
135        assert_eq!(commit.scope, None);
136        assert!(!commit.breaking);
137    }
138    
139    #[test]
140    fn test_parse_fix_with_scope() {
141        let commit = ConventionalCommit::parse("fix(auth): resolve login bug").unwrap();
142        assert_eq!(commit.commit_type, CommitType::Fix);
143        assert_eq!(commit.description, "resolve login bug");
144        assert_eq!(commit.scope, Some("auth".to_string()));
145        assert!(!commit.breaking);
146    }
147    
148    #[test]
149    fn test_parse_breaking_with_indicator() {
150        let commit = ConventionalCommit::parse("feat!: breaking change").unwrap();
151        assert_eq!(commit.commit_type, CommitType::Feat);
152        assert!(commit.breaking);
153    }
154    
155    #[test]
156    fn test_parse_breaking_in_body() {
157        let commit = ConventionalCommit::parse("feat: new feature\n\nBREAKING CHANGE: API changed").unwrap();
158        assert!(commit.breaking);
159    }
160}