Skip to main content

commitlint_rs/
git.rs

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