commitlint_rs/
git.rs

1use regex::Regex;
2use std::{collections::HashMap, process::Command};
3/// ReadCommitMessageOptions represents the options for reading commit messages.
4/// Transparently, it is defined to be similar to the behavior of the git log command.
5#[derive(Clone, Debug)]
6pub struct ReadCommitMessageOptions {
7    /// From is the starting commit hash to read from.
8    pub from: Option<String>,
9
10    /// Path is the path to read commit messages from.
11    pub path: String,
12
13    /// To is the ending commit hash to read to.
14    pub to: Option<String>,
15}
16
17/// Get commit messages from git.
18pub fn read(options: ReadCommitMessageOptions) -> Vec<String> {
19    // Configure revision range following the git spec.
20    //
21    // See: https://git-scm.com/docs/git-log#Documentation/git-log.txt-ltrevision-rangegt
22    //
23    // Make a range if both `from` and `to` are specified, then assign from..to.
24    // If both are not specified, then assign HEAD.
25    let range = match (options.from, options.to) {
26        (Some(from), Some(to)) => format!("{}..{}", from, to),
27        (Some(from), None) => format!("{}..HEAD", from),
28        (None, Some(to)) => format!("HEAD..{}", to),
29        (None, None) => "HEAD".to_string(),
30    };
31
32    // See https://git-scm.com/docs/git-log
33    let stdout = Command::new("git")
34        .arg("log")
35        .arg("--pretty=%B")
36        .arg("--no-merges")
37        .arg("--no-decorate")
38        .arg("--reverse")
39        .arg(range)
40        .arg("--") // Explicitly specify the end of options as described https://git-scm.com/docs/git-log#Documentation/git-log.txt---ltpathgt82308203
41        .arg(options.path)
42        .output()
43        .expect("Failed to execute git log")
44        .stdout;
45
46    let stdout = String::from_utf8_lossy(&stdout);
47    extract_commit_messages(&stdout)
48}
49
50fn extract_commit_messages(input: &str) -> Vec<String> {
51    let commit_delimiter = Regex::new(r"(?m)^commit [0-9a-f]{40}$").unwrap();
52    let commits: Vec<&str> = commit_delimiter.split(input).collect();
53
54    let mut messages: Vec<String> = Vec::new();
55
56    for commit in commits {
57        let message_lines: Vec<&str> = commit.trim().lines().collect();
58        let message = message_lines.join("\n");
59        messages.push(message);
60    }
61
62    messages
63}
64
65/// Parse a commit message and return the subject, body, and footers.
66///
67/// Please refer the official documentation for the commit message format.
68/// See: https://www.conventionalcommits.org/en/v1.0.0/#summary
69///
70/// ```ignore
71/// <type>[optional scope]: <description> <-- Subject
72///
73/// [optional body] <-- Body
74///
75/// [optional footer(s)] <-- Footer
76/// ```
77pub fn parse_commit_message(
78    message: &str,
79) -> (String, Option<String>, Option<HashMap<String, String>>) {
80    let lines: Vec<&str> = message.lines().collect();
81    let mut lines_iter = lines.iter();
82
83    let subject = lines_iter.next().unwrap_or(&"").trim().to_string();
84    let mut body = None;
85    let mut footer = None;
86
87    let mut in_body = false;
88    let mut in_footer = false;
89
90    for line in lines_iter {
91        if line.trim().is_empty() {
92            if in_body {
93                in_body = false;
94                in_footer = true;
95            }
96        } else if in_footer {
97            let parts: Vec<&str> = line.splitn(2, ':').map(|part| part.trim()).collect();
98            if parts.len() == 2 {
99                let key = parts[0].to_string();
100                let value = parts[1].to_string();
101                let footer_map = footer.get_or_insert(HashMap::new());
102                footer_map.insert(key, value);
103            }
104        } else if !in_body {
105            in_body = true;
106            body = Some(line.trim().to_string());
107        } else if let Some(b) = body.as_mut() {
108            b.push('\n');
109            b.push_str(line.trim());
110        }
111    }
112
113    (subject, body, footer)
114}
115
116/// Parse a commit message subject and return the type, scope, and description.
117///
118/// Note that exclamation mark is not respected as the existing commitlint
119/// does not have any rules for it.
120/// See: https://commitlint.js.org/reference/rules.html
121pub fn parse_subject(subject: &str) -> (Option<String>, Option<String>, Option<String>) {
122    let re = regex::Regex::new(
123        r"^(?P<type>\w+)(?:\((?P<scope>[^\)]+)\))?(?:!)?\:\s?(?P<description>.*)$",
124    )
125    .unwrap();
126    if let Some(captures) = re.captures(subject) {
127        let r#type = captures.name("type").map(|m| m.as_str().to_string());
128        let scope = captures.name("scope").map(|m| m.as_str().to_string());
129        let description = captures.name("description").map(|m| m.as_str().to_string());
130
131        return (r#type, scope, description);
132    }
133    // Fall back to the description.
134    (None, None, Some(subject.to_string()))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_single_line_parse_commit_message() {
143        let input = "feat(cli): add dummy option";
144        let (subject, body, footer) = parse_commit_message(input);
145        assert_eq!(subject, "feat(cli): add dummy option");
146        assert_eq!(body, None);
147        assert_eq!(footer, None);
148    }
149
150    #[test]
151    fn test_body_parse_commit_message() {
152        let input = "feat(cli): add dummy option
153
154Hello, there!";
155        let (subject, body, footer) = parse_commit_message(input);
156        assert_eq!(subject, "feat(cli): add dummy option");
157        assert_eq!(body, Some("Hello, there!".to_string()));
158        assert_eq!(footer, None);
159    }
160
161    #[test]
162    fn test_footer_parse_commit_message() {
163        let input = "feat(cli): add dummy option
164
165Hello, there!
166
167Link: Hello";
168        let (subject, body, footer) = parse_commit_message(input);
169
170        let mut f = HashMap::new();
171        f.insert("Link".to_string(), "Hello".to_string());
172        assert_eq!(subject, "feat(cli): add dummy option");
173        assert_eq!(body, Some("Hello, there!".to_string()));
174        assert!(footer.is_some());
175        assert_eq!(f.get("Link"), Some(&"Hello".to_string()));
176    }
177
178    #[test]
179    fn test_footer_with_multiline_body_parse_commit_message() {
180        let input = "feat(cli): add dummy option
181
182Hello, there!
183I'm from Japan!
184
185Link: Hello";
186        let (subject, body, footer) = parse_commit_message(input);
187
188        let mut f = HashMap::new();
189        f.insert("Link".to_string(), "Hello".to_string());
190        assert_eq!(subject, "feat(cli): add dummy option");
191        assert_eq!(
192            body,
193            Some(
194                "Hello, there!
195I'm from Japan!"
196                    .to_string()
197            )
198        );
199        assert!(footer.is_some());
200        assert_eq!(f.get("Link"), Some(&"Hello".to_string()));
201    }
202
203    #[test]
204    fn test_multiple_footers_parse_commit_message() {
205        let input = "feat(cli): add dummy option
206
207Hello, there!
208
209Link: Hello
210Name: Keke";
211        let (subject, body, footer) = parse_commit_message(input);
212
213        assert_eq!(subject, "feat(cli): add dummy option");
214        assert_eq!(body, Some("Hello, there!".to_string()));
215        assert!(footer.is_some());
216        assert_eq!(
217            footer.clone().unwrap().get("Link"),
218            Some(&"Hello".to_string())
219        );
220        assert_eq!(footer.unwrap().get("Name"), Some(&"Keke".to_string()));
221    }
222
223    #[test]
224    fn test_parse_subject_with_scope() {
225        let input = "feat(cli): add dummy option";
226        assert_eq!(
227            parse_subject(input),
228            (
229                Some("feat".to_string()),
230                Some("cli".to_string()),
231                Some("add dummy option".to_string())
232            )
233        );
234    }
235
236    #[test]
237    fn test_parse_subject_with_emphasized_type_with_scope() {
238        let input = "feat(cli)!: add dummy option";
239        assert_eq!(
240            parse_subject(input),
241            (
242                Some("feat".to_string()),
243                Some("cli".to_string()),
244                Some("add dummy option".to_string())
245            )
246        );
247    }
248
249    #[test]
250    fn test_parse_subject_without_scope() {
251        let input = "feat: add dummy option";
252        assert_eq!(
253            parse_subject(input),
254            (
255                Some("feat".to_string()),
256                None,
257                Some("add dummy option".to_string())
258            )
259        );
260    }
261
262    #[test]
263    fn test_parse_subject_with_emphasized_type_without_scope() {
264        let input = "feat!: add dummy option";
265        assert_eq!(
266            parse_subject(input),
267            (
268                Some("feat".to_string()),
269                None,
270                Some("add dummy option".to_string())
271            )
272        );
273    }
274
275    #[test]
276    fn test_parse_subject_with_empty_description() {
277        let input = "feat(cli): ";
278        assert_eq!(
279            parse_subject(input),
280            (
281                Some("feat".to_string()),
282                Some("cli".to_string()),
283                Some("".to_string())
284            )
285        );
286    }
287
288    #[test]
289    fn test_parse_subject_with_empty_scope() {
290        let input = "feat: add dummy commit";
291        assert_eq!(
292            parse_subject(input),
293            (
294                Some("feat".to_string()),
295                None,
296                Some("add dummy commit".to_string())
297            )
298        );
299    }
300
301    #[test]
302    fn test_parse_subject_without_message() {
303        let input = "";
304        assert_eq!(parse_subject(input), (None, None, Some("".to_string())));
305    }
306
307    #[test]
308    fn test_parse_subject_with_error_message() {
309        let input = "test";
310        assert_eq!(parse_subject(input), (None, None, Some("test".to_string())));
311    }
312}