1use std::{collections::BTreeMap, fmt::Display, io::BufRead, str::FromStr};
10
11use indexmap::IndexMap;
12use lazy_static::lazy_static;
13use regex::Regex;
14
15pub use crate::util::StringExt;
16
17mod util;
18
19lazy_static! {
20 pub static ref DEFAULT_CONVCO_TYPES: BTreeMap<&'static str, &'static str> = {
22 let mut m = BTreeMap::new();
23 m.insert("feat", "New features");
24 m.insert("fix", "Bug fixes");
25 m.insert("docs", "Documentation");
26 m.insert("style", "Code styling");
27 m.insert("refactor", "Code refactoring");
28 m.insert("perf", "Performance Improvements");
29 m.insert("test", "Testing");
30 m.insert("build", "Build system");
31 m.insert("ci", "Continuous Integration");
32 m.insert("cd", "Continuous Delivery");
33 m.insert("chore", "Other changes");
34 m
35 };
36}
37
38pub const DEFAULT_CONVCO_INCR_MINOR_TYPES: [&str; 1] = ["feat"];
40
41pub const BREAKING_CHANGE_KEY: &str = "BREAKING CHANGE";
43
44pub const BREAKING_CHANGE_KEY_DASH: &str = "BREAKING-CHANGE";
46
47#[derive(Debug, Default, Clone)]
49pub struct ConvcoMessage {
50 pub r#type: String,
52 pub scope: Option<String>,
54 pub is_breaking: bool,
56 pub desc: String,
58 pub body: Option<String>,
60 pub footer: Option<IndexMap<String, String>>,
64}
65
66impl ConvcoMessage {
67 pub fn add_breaking_change(&mut self, desc: &str) -> &mut Self {
69 self.is_breaking = true;
70 if let Some(entries) = &mut self.footer {
71 entries.insert(BREAKING_CHANGE_KEY.to_string(), desc.to_string());
72 }
73 self
74 }
75
76 pub fn add_footer_note(&mut self, key: &str, value: &str) -> &mut Self {
78 if let Some(entries) = &mut self.footer {
79 entries.insert(key.to_string(), value.to_string());
80 } else {
81 let mut entries = IndexMap::new();
82 entries.insert(key.to_string(), value.to_string());
83 self.footer = Some(entries);
84 }
85
86 self
87 }
88
89 pub fn is_breaking_change(&self) -> bool {
91 if self.is_breaking {
92 return true;
93 }
94
95 if let Some(entries) = &self.footer {
96 return entries.contains_key(BREAKING_CHANGE_KEY);
97 }
98 false
99 }
100}
101
102#[derive(Debug, thiserror::Error)]
104#[error("Invalid conventional commit:{0}")]
105pub struct ConvcoError(String);
106
107impl Display for ConvcoMessage {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 write!(
110 f,
111 "{}{}{}: {}",
112 self.r#type,
113 self.scope
114 .as_ref()
115 .map(|s| format!("({s})"))
116 .unwrap_or_default(),
117 if self.is_breaking { "!" } else { "" },
118 self.desc
119 )?;
120
121 if let Some(b) = &self.body {
123 write!(f, "\n\n")?;
124 write!(f, "{b}")?;
125 }
126
127 if let Some(entries) = &self.footer {
129 write!(f, "\n\n")?;
130 let mut it = entries.iter().peekable();
131 while let Some((k, v)) = it.next() {
132 if it.peek().is_none() {
133 write!(f, "{k}: {v}")?;
135 } else {
136 writeln!(f, "{k}: {v}")?;
137 }
138 }
139 }
140
141 Ok(())
142 }
143}
144
145lazy_static! {
147 static ref REGEX_SUBJECT: Regex = Regex::new(
155 r"(?P<type>[[:word:]]+)(?P<scope>[\(][[:word:]]+[\)])?(?P<breaking>[!])?: (?P<desc>.*)"
156 )
157 .expect("Invalid regex");
158}
159
160lazy_static! {
161 static ref REGEX_FOOTER_KV: Regex =
162 Regex::new(r"(?P<key>.*): (?P<value>.*)").expect("Invalid regex");
163}
164
165impl FromStr for ConvcoMessage {
166 type Err = ConvcoError;
167
168 fn from_str(s: &str) -> Result<Self, Self::Err> {
169 #[derive(Debug, PartialEq)]
170 enum Section {
171 Subject,
172 Body,
173 Footer,
174 }
175
176 let mut r#type = String::new();
177 let mut scope: Option<String> = None;
178 let mut is_breaking: bool = false;
179 let mut desc = String::new();
180 let mut body: Option<String> = None;
181 let mut footer: Option<IndexMap<String, String>> = None;
182
183 let mut this_section = Section::Subject;
185 let mut is_prev_line_empty = false;
186 for (i, line_res) in s.as_bytes().lines().enumerate() {
187 let line = line_res.map_err(|e| ConvcoError(e.to_string()))?;
188
189 match (i, &this_section) {
190 (0, Section::Subject) => {
191 let caps = match REGEX_SUBJECT.captures(s) {
193 Some(caps) => caps,
194 None => {
195 return Err(ConvcoError("invalid subject line".to_string()));
196 }
197 };
198 if let Some(ok) = caps.name("type") {
199 r#type = ok.as_str().to_string();
200 if r#type.is_empty() || !r#type.is_lowercase() {
201 return Err(ConvcoError(
202 "type must non empty and lowercase".to_string(),
203 ));
204 }
205 } else {
206 return Err(ConvcoError("missing subject type".to_string()));
207 };
208 if let Some(ok) = caps.name("scope") {
209 let scope_raw = ok.as_str();
210 let scope_raw = scope_raw.strip_prefix('(').unwrap();
211 let scope_raw = scope_raw.strip_suffix(')').unwrap();
212 if scope_raw.is_empty() || !scope_raw.is_lowercase() {
213 return Err(ConvcoError(
214 "scope must non empty and lowercase".to_string(),
215 ));
216 }
217 scope = Some(scope_raw.to_string());
218 };
219 if caps.name("breaking").is_some() {
220 is_breaking = true;
221 };
222 match caps.name("desc") {
223 Some(ok) => {
224 desc = ok.as_str().to_string();
225 if !desc.starts_with_lowercase() {
226 return Err(ConvcoError(
227 "subject must start with lowercase".to_string(),
228 ));
229 }
230 }
231 None => {
232 return Err(ConvcoError("missing subject description".to_string()));
233 }
234 };
235 }
236 (1, Section::Subject) => {
237 if line.is_empty() {
239 is_prev_line_empty = true;
240 this_section = Section::Body;
241 } else {
242 return Err(ConvcoError(
243 "body must be separated by an empty line".to_string(),
244 ));
245 }
246 }
247 (_, Section::Subject) => {
248 unreachable!()
249 }
250 (_, Section::Body) => {
251 if is_prev_line_empty {
261 if let Some(caps) = REGEX_FOOTER_KV.captures(&line) {
262 let key = caps.name("key").unwrap().as_str();
263 if is_valid_footer_token(key) {
264 let value = caps.name("value").unwrap().as_str();
266 let mut m = IndexMap::new();
267 m.insert(key.to_string(), value.to_string());
268 footer = Some(m);
269 is_prev_line_empty = false;
270 this_section = Section::Footer;
271 body = body.map(|b| b.trim().to_string());
273 continue;
274 }
275 };
276 }
277
278 let mut b = if let Some(mut b) = body {
279 b.push('\n');
280 b
281 } else {
282 "".to_string()
283 };
284 b.push_str(&line);
285 body = Some(b);
286 is_prev_line_empty = line.is_empty();
287 }
288 (_, Section::Footer) => {
289 if let Some(caps) = REGEX_FOOTER_KV.captures(&line) {
290 let key = caps.name("key").unwrap().as_str();
291 if is_valid_footer_token(key) {
292 let value = caps.name("value").unwrap().as_str();
294 if let Some(f) = &mut footer {
295 f.insert(key.to_string(), value.to_string());
296 } else {
297 unreachable!()
298 }
299 } else {
300 return Err(ConvcoError(format!("invalid footer key: '{key}'")));
301 }
302 } else {
303 return Err(ConvcoError("invalid footer line".to_string()));
304 };
305 }
306 }
307 }
308
309 Ok(Self {
311 r#type,
312 scope,
313 is_breaking,
314 desc,
315 body,
316 footer,
317 })
318 }
319}
320
321fn is_valid_footer_token(value: &str) -> bool {
325 if value.contains(' ') {
326 return value == BREAKING_CHANGE_KEY;
327 }
328 true
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn regex_footer_simple() {
337 let s = "Refs: #123";
338 let caps = REGEX_FOOTER_KV.captures(s).unwrap();
339 assert_eq!(caps.name("key").unwrap().as_str(), "Refs");
341 assert_eq!(caps.name("value").unwrap().as_str(), "#123");
342 }
343
344 #[test]
345 fn regex_footer_breaking_change() {
346 let s = "BREAKING CHANGE: This is a breaking change";
347 let caps = REGEX_FOOTER_KV.captures(s).unwrap();
348 assert_eq!(caps.name("key").unwrap().as_str(), "BREAKING CHANGE");
350 assert_eq!(
351 caps.name("value").unwrap().as_str(),
352 "This is a breaking change"
353 );
354 }
355
356 #[test]
357 fn regex_subject_simple() {
358 let s = "feat: A new feature";
359 let caps = REGEX_SUBJECT.captures(s).unwrap();
360 assert_eq!(caps.name("type").unwrap().as_str(), "feat");
362 assert!(caps.name("scope").is_none());
363 assert!(caps.name("breaking").is_none());
364 assert_eq!(caps.name("desc").unwrap().as_str(), "A new feature");
365 }
366
367 #[test]
368 fn regex_subject_excl() {
369 let s = "feat!: A new feature";
370 let caps = REGEX_SUBJECT.captures(s).unwrap();
371 assert_eq!(caps.name("type").unwrap().as_str(), "feat");
373 assert!(caps.name("scope").is_none());
374 assert_eq!(caps.name("breaking").unwrap().as_str(), "!");
375 assert_eq!(caps.name("desc").unwrap().as_str(), "A new feature");
376 }
377
378 #[test]
379 fn regex_subject_scope() {
380 let s = "feat(abc): A new feature";
381 let caps = REGEX_SUBJECT.captures(s).unwrap();
382 assert_eq!(caps.name("type").unwrap().as_str(), "feat");
384 assert_eq!(caps.name("scope").unwrap().as_str(), "(abc)");
385 assert!(caps.name("breaking").is_none());
386 assert_eq!(caps.name("desc").unwrap().as_str(), "A new feature");
387 }
388
389 #[test]
390 fn regex_subject_scope_excl() {
391 let s = "feat(abc)!: A new feature";
392 let caps = REGEX_SUBJECT.captures(s).unwrap();
393 assert_eq!(caps.name("type").unwrap().as_str(), "feat");
395 assert_eq!(caps.name("scope").unwrap().as_str(), "(abc)");
396 assert_eq!(caps.name("breaking").unwrap().as_str(), "!");
397 assert_eq!(caps.name("desc").unwrap().as_str(), "A new feature");
398 }
399}