torii_lib/versioning/
conventional.rs1use 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 pub fn parse(message: &str) -> Result<Self> {
52 let message = message.trim();
53
54 let breaking_in_body = message.contains("BREAKING CHANGE:");
56
57 let first_line = message.lines().next().unwrap_or("");
59
60 let breaking_indicator = first_line.contains('!');
62
63 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 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 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 let body = if message.lines().count() > 1 {
99 Some(message.lines().skip(1).collect::<Vec<_>>().join("\n"))
100 } else {
101 None
102 };
103
104 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}