use regex::Regex;
use std::{collections::HashMap, process::Command};
#[derive(Clone, Debug)]
pub struct ReadCommitMessageOptions {
pub from: Option<String>,
pub path: String,
pub to: Option<String>,
}
pub fn read(options: ReadCommitMessageOptions) -> Vec<String> {
let range = match (options.from, options.to) {
(Some(from), Some(to)) => format!("{}..{}", from, to),
(Some(from), None) => format!("{}..HEAD", from),
(None, Some(to)) => format!("HEAD..{}", to),
(None, None) => "HEAD".to_string(),
};
let stdout = Command::new("git")
.arg("log")
.arg("--pretty=%B")
.arg("--no-merges")
.arg("--no-decorate")
.arg("--reverse")
.arg(range)
.arg("--") .arg(options.path)
.output()
.expect("Failed to execute git log")
.stdout;
let stdout = String::from_utf8_lossy(&stdout);
extract_commit_messages(&stdout)
}
fn extract_commit_messages(input: &str) -> Vec<String> {
let commit_delimiter = Regex::new(r"(?m)^commit [0-9a-f]{40}$").unwrap();
let commits: Vec<&str> = commit_delimiter.split(input).collect();
let mut messages: Vec<String> = Vec::new();
for commit in commits {
let message_lines: Vec<&str> = commit.trim().lines().collect();
let message = message_lines.join("\n");
messages.push(message);
}
messages
}
pub fn parse_commit_message(
message: &str,
) -> (String, Option<String>, Option<HashMap<String, String>>) {
let lines: Vec<&str> = message.lines().collect();
let mut lines_iter = lines.iter();
let subject = lines_iter.next().unwrap_or(&"").trim().to_string();
let mut body = None;
let mut footer = None;
let mut in_body = false;
let mut in_footer = false;
for line in lines_iter {
if line.trim().is_empty() {
if in_body {
in_body = false;
in_footer = true;
}
} else if in_footer {
let parts: Vec<&str> = line.splitn(2, ':').map(|part| part.trim()).collect();
if parts.len() == 2 {
let key = parts[0].to_string();
let value = parts[1].to_string();
let footer_map = footer.get_or_insert(HashMap::new());
footer_map.insert(key, value);
}
} else if !in_body {
in_body = true;
body = Some(line.trim().to_string());
} else if let Some(b) = body.as_mut() {
b.push('\n');
b.push_str(line.trim());
}
}
(subject, body, footer)
}
pub fn parse_subject(subject: &str) -> (Option<String>, Option<String>, Option<String>) {
let re = regex::Regex::new(
r"^(?P<type>\w+)(?:\((?P<scope>[^\)]+)\))?(?:!)?\:\s?(?P<description>.*)$",
)
.unwrap();
if let Some(captures) = re.captures(subject) {
let r#type = captures.name("type").map(|m| m.as_str().to_string());
let scope = captures.name("scope").map(|m| m.as_str().to_string());
let description = captures.name("description").map(|m| m.as_str().to_string());
return (r#type, scope, description);
}
(None, None, Some(subject.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_line_parse_commit_message() {
let input = "feat(cli): add dummy option";
let (subject, body, footer) = parse_commit_message(input);
assert_eq!(subject, "feat(cli): add dummy option");
assert_eq!(body, None);
assert_eq!(footer, None);
}
#[test]
fn test_body_parse_commit_message() {
let input = "feat(cli): add dummy option
Hello, there!";
let (subject, body, footer) = parse_commit_message(input);
assert_eq!(subject, "feat(cli): add dummy option");
assert_eq!(body, Some("Hello, there!".to_string()));
assert_eq!(footer, None);
}
#[test]
fn test_footer_parse_commit_message() {
let input = "feat(cli): add dummy option
Hello, there!
Link: Hello";
let (subject, body, footer) = parse_commit_message(input);
let mut f = HashMap::new();
f.insert("Link".to_string(), "Hello".to_string());
assert_eq!(subject, "feat(cli): add dummy option");
assert_eq!(body, Some("Hello, there!".to_string()));
assert!(footer.is_some());
assert_eq!(f.get("Link"), Some(&"Hello".to_string()));
}
#[test]
fn test_footer_with_multiline_body_parse_commit_message() {
let input = "feat(cli): add dummy option
Hello, there!
I'm from Japan!
Link: Hello";
let (subject, body, footer) = parse_commit_message(input);
let mut f = HashMap::new();
f.insert("Link".to_string(), "Hello".to_string());
assert_eq!(subject, "feat(cli): add dummy option");
assert_eq!(
body,
Some(
"Hello, there!
I'm from Japan!"
.to_string()
)
);
assert!(footer.is_some());
assert_eq!(f.get("Link"), Some(&"Hello".to_string()));
}
#[test]
fn test_multiple_footers_parse_commit_message() {
let input = "feat(cli): add dummy option
Hello, there!
Link: Hello
Name: Keke";
let (subject, body, footer) = parse_commit_message(input);
assert_eq!(subject, "feat(cli): add dummy option");
assert_eq!(body, Some("Hello, there!".to_string()));
assert!(footer.is_some());
assert_eq!(
footer.clone().unwrap().get("Link"),
Some(&"Hello".to_string())
);
assert_eq!(footer.unwrap().get("Name"), Some(&"Keke".to_string()));
}
#[test]
fn test_parse_subject_with_scope() {
let input = "feat(cli): add dummy option";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
Some("cli".to_string()),
Some("add dummy option".to_string())
)
);
}
#[test]
fn test_parse_subject_with_emphasized_type_with_scope() {
let input = "feat(cli)!: add dummy option";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
Some("cli".to_string()),
Some("add dummy option".to_string())
)
);
}
#[test]
fn test_parse_subject_without_scope() {
let input = "feat: add dummy option";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
None,
Some("add dummy option".to_string())
)
);
}
#[test]
fn test_parse_subject_with_emphasized_type_without_scope() {
let input = "feat!: add dummy option";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
None,
Some("add dummy option".to_string())
)
);
}
#[test]
fn test_parse_subject_with_empty_description() {
let input = "feat(cli): ";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
Some("cli".to_string()),
Some("".to_string())
)
);
}
#[test]
fn test_parse_subject_with_empty_scope() {
let input = "feat: add dummy commit";
assert_eq!(
parse_subject(input),
(
Some("feat".to_string()),
None,
Some("add dummy commit".to_string())
)
);
}
#[test]
fn test_parse_subject_without_message() {
let input = "";
assert_eq!(parse_subject(input), (None, None, Some("".to_string())));
}
#[test]
fn test_parse_subject_with_error_message() {
let input = "test";
assert_eq!(parse_subject(input), (None, None, Some("test".to_string())));
}
}