gitcc_convco/
lib.rs

1//! Conventional commits
2//!
3//! This crate provides the tools to work with conventional commits.
4
5//! Conventional commits
6//!
7//! This module is based on [Conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
8
9use 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    /// Default conventional commit types
21    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
38/// Default commit types which increment the minor version
39pub const DEFAULT_CONVCO_INCR_MINOR_TYPES: [&str; 1] = ["feat"];
40
41/// Breaking change key
42pub const BREAKING_CHANGE_KEY: &str = "BREAKING CHANGE";
43
44/// Breaking change key (with dash)
45pub const BREAKING_CHANGE_KEY_DASH: &str = "BREAKING-CHANGE";
46
47/// Conventional commit message
48#[derive(Debug, Default, Clone)]
49pub struct ConvcoMessage {
50    /// Commit type
51    pub r#type: String,
52    /// Commit scope
53    pub scope: Option<String>,
54    /// Indicates that this is a breaking change (!)
55    pub is_breaking: bool,
56    /// Commit description
57    pub desc: String,
58    /// Commit body
59    pub body: Option<String>,
60    /// Footer
61    ///
62    /// A footer must be a list of key: value pairs following the git trailer convention
63    pub footer: Option<IndexMap<String, String>>,
64}
65
66impl ConvcoMessage {
67    /// Sets a breaking change
68    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    /// Inserts a footer note
77    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    /// Checks if the message has a breaking change
90    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/// Conventional commit error
103#[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        // Body
122        if let Some(b) = &self.body {
123            write!(f, "\n\n")?;
124            write!(f, "{b}")?;
125        }
126
127        // Footer
128        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                    // NB: last entry
134                    write!(f, "{k}: {v}")?;
135                } else {
136                    writeln!(f, "{k}: {v}")?;
137                }
138            }
139        }
140
141        Ok(())
142    }
143}
144
145// cf. https://2fd.github.io/rust-regex-playground
146lazy_static! {
147    /// Regex to parse the subject
148    ///
149    /// The following groups are defined
150    /// - type: eg. feat
151    /// - scope: eg. (abcd)
152    /// - breaking: eg. ! or ""
153    /// - subject: eg. long text
154    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        // We parse starting from the bottom
184        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                    // => parse 1st line
192                    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                    // >> line after subject
238                    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                    // eprintln!("{:#?}", line);
252
253                    // NOTES:
254                    // OK here it gets a bit tricky to split the body and the footer.
255                    // The footer is the last paragraph and must be a list of key-value pairs (K: V),
256                    // where K is a word token (separation is made with '-', with the execption of the value 'BREAKING CHANGE').
257                    //
258                    // So, we use the heuristics that, for each line, if the previous line is blank,
259                    // and if that line starts with a valid key/value pair, we are now part of the footer.
260                    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                                // => we are part of the footer
265                                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                                // NB: removing the '\n' at the end of the body
272                                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                            // => we are part of the footer
293                            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        // ⮑
310        Ok(Self {
311            r#type,
312            scope,
313            is_breaking,
314            desc,
315            body,
316            footer,
317        })
318    }
319}
320
321/// Checks if a string is valid footer token
322///
323/// A valid footer token is a word token with no white space, with the exception of the value 'BREAKING CHANGE'
324fn 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        // eprintln!("{caps:#?}");
340        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        // eprintln!("{caps:#?}");
349        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        // eprintln!("{caps:#?}");
361        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        // eprintln!("{caps:#?}");
372        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        // eprintln!("{caps:#?}");
383        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        // eprintln!("{caps:#?}");
394        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}