1use regex::Regex;
2use std::{collections::HashMap, process::Command};
3#[derive(Clone, Debug)]
6pub struct ReadCommitMessageOptions {
7 pub from: Option<String>,
9
10 pub path: String,
12
13 pub to: Option<String>,
15}
16
17pub fn read(options: ReadCommitMessageOptions) -> Vec<String> {
19 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 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("--") .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
65pub 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
116pub 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 (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}