1use regex::Regex;
2use std::path::PathBuf;
3use std::{collections::HashMap, process::Command};
4#[derive(Clone, Debug)]
7pub struct ReadCommitMessageOptions {
8 pub from: Option<String>,
10
11 pub path: String,
13
14 pub to: Option<String>,
16}
17
18pub 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
46pub fn read(options: ReadCommitMessageOptions) -> Vec<String> {
48 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 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("--") .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
94pub 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
145pub 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 (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}