use core::ops::Range;
use crate::commit::Commit;
use crate::issue::{Context, Issue, Position};
use crate::rule::Rule;
use crate::rule::RuleValidator;
use crate::rules::CONTAINS_FIX_TICKET;
use crate::utils::display_width;
pub struct MessagePresence {}
impl MessagePresence {
pub fn new() -> Self {
Self {}
}
}
impl RuleValidator<Commit> for MessagePresence {
fn validate(&self, commit: &Commit) -> Option<Vec<Issue>> {
let message_without_line_breaks = &commit
.message
.trim()
.lines()
.filter(|l| !l.is_empty())
.collect::<Vec<&str>>()
.join("");
let mut width = display_width(message_without_line_breaks);
if width == 0 {
let context = vec![
Context::subject(commit.subject.to_string()),
Context::message_line(2, "".to_string()),
Context::message_line_addition(
3,
"".to_string(),
Range { start: 0, end: 3 },
"Add a message that describes the change and why it was made".to_string(),
),
];
return Some(vec![Issue::error(
Rule::MessagePresence,
"No message body was found".to_string(),
Position::MessageLine { line: 3, column: 1 },
context,
)]);
}
let issues = issues_for_lines_with_only_ticket_numbers(&commit.message);
if issues.is_some() {
return issues;
}
width -= ticket_number_reference_length(&commit.message);
if width < 10 {
let mut context = vec![];
let message = commit.message.trim_end();
let line_length = message.lines().count();
for (line_number, line) in message.lines().enumerate() {
if line_number == 0 && line.is_empty() {
continue;
}
let human_line_number = line_number + 2;
if line_number + 1 == line_length {
context.push(Context::message_line_error(
human_line_number,
line.to_string(),
Range {
start: 0,
end: line.len(),
},
"Add more detail about the change and why it was made".to_string(),
));
} else if line.trim().is_empty() {
context.push(Context::message_line(human_line_number, line.to_string()));
} else {
context.push(Context::message_line_error_without_message(
human_line_number,
line.to_string(),
Range {
start: 0,
end: line.len(),
},
));
}
}
let line_number_of_start_of_issue = if commit.message.starts_with('\n') {
3
} else {
2
};
return Some(vec![Issue::error(
Rule::MessagePresence,
"The message body is too short".to_string(),
Position::MessageLine {
line: line_number_of_start_of_issue,
column: 1,
},
context,
)]);
}
None
}
}
fn issues_for_lines_with_only_ticket_numbers(message: &str) -> Option<Vec<Issue>> {
let mut context = vec![];
let mut ticket_starting_line_number = None;
let lines = message.lines();
for (line_number, line) in lines.enumerate() {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
continue;
}
{
let capture = scan_for_ticket_number(line)?;
let line_label = line_number + 2;
let capture_str = capture.as_str();
let capture_len = capture_str.len();
if trimmed_line.len() == capture_len {
if ticket_starting_line_number.is_none() {
ticket_starting_line_number = Some(line_label);
}
context.push(Context::message_line_error(
line_label,
capture_str.to_string(),
Range {
start: 0,
end: capture_len,
},
"Add more detail about the change and why it was made".to_string(),
));
} else {
return None;
}
}
}
if context.is_empty() {
None
} else {
Some(vec![Issue::error(
Rule::MessagePresence,
"The message body is only a reference to a ticket number".to_string(),
Position::MessageLine {
line: ticket_starting_line_number.unwrap_or(2),
column: 1,
},
context,
)])
}
}
fn scan_for_ticket_number(message: &str) -> Option<regex::Match<'_>> {
if let Some(captures) = CONTAINS_FIX_TICKET.captures(message) {
match captures.get(0) {
Some(capture) => return Some(capture),
None => {
error!("MessagePresence: Unable to fetch ticket number match from message.");
}
}
}
None
}
fn ticket_number_reference_length(message: &str) -> usize {
let mut length = 0;
let lines = message.lines();
for line in lines {
let trimmed_line = line.trim();
if trimmed_line.is_empty() {
continue;
}
if let Some(capture) = scan_for_ticket_number(line) {
let capture_width = display_width(capture.as_str());
length += capture_width;
}
}
length
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::*;
fn validate(commit: &Commit) -> Option<Vec<Issue>> {
MessagePresence::new().validate(commit)
}
#[test]
fn with_message() {
let with_message = commit("Subject".to_string(), "Hello I am a message.".to_string());
assert_eq!(validate(&with_message), None);
}
#[test]
fn without_message() {
let without_message = commit("Subject", "");
let issue = first_issue(validate(&without_message));
assert_eq!(issue.message, "No message body was found");
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"1 | Subject\n\
2 | \n\
3 | \n\
| +++ Add a message that describes the change and why it was made",
);
}
#[test]
fn with_only_line_numbers() {
let commit = commit("Subject", &"\n".repeat(11));
let issues = validate(&commit);
assert!(issues.is_some());
}
#[test]
fn with_short_message() {
let short = commit("Subject", "\nShort.");
let issue = first_issue(validate(&short));
assert_eq!(issue.message, "The message body is too short");
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"3 | Short.\n\
| ^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_very_short_message() {
let very_short = commit("Subject".to_string(), "WIP".to_string());
let issue = first_issue(validate(&very_short));
assert_eq!(issue.message, "The message body is too short");
assert_eq!(issue.position, message_position(2, 1));
assert_contains_issue_output(
&issue,
"2 | WIP\n\
| ^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_very_short_multi_line_message() {
let very_short = commit("Subject".to_string(), "\n.\n.\n\nShort.\n".to_string());
let issues = validate(&very_short);
let issue = first_issue(issues);
assert_eq!(issue.message, "The message body is too short");
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"3 | .\n\
| ^\n\
4 | .\n\
| ^\n\
5 | \n\
6 | Short.\n\
| ^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_only_ticket_number() {
let ticket_only = commit("Subject".to_string(), "\nCloses #123\n".to_string());
let issue = first_issue(validate(&ticket_only));
assert_eq!(
issue.message,
"The message body is only a reference to a ticket number"
);
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"3 | Closes #123\n\
| ^^^^^^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_multiple_ticket_numbers() {
let tickets_only = commit(
"Subject".to_string(),
"\nImplements #123\nCloses #234\n".to_string(),
);
let issue = first_issue(validate(&tickets_only));
assert_eq!(
issue.message,
"The message body is only a reference to a ticket number"
);
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"3 | Implements #123\n\
| ^^^^^^^^^^^^^^^\n\
4 | Closes #234\n\
| ^^^^^^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_only_ticket_number_on_second_line() {
let ticket_only = commit("Subject".to_string(), "Closes #123\n".to_string());
let issue = first_issue(validate(&ticket_only));
assert_eq!(
issue.message,
"The message body is only a reference to a ticket number"
);
assert_eq!(issue.position, message_position(2, 1));
assert_contains_issue_output(
&issue,
"2 | Closes #123\n\
| ^^^^^^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_only_ticket_number_on_forth_line() {
let ticket_only = commit("Subject".to_string(), "\n\nCloses #123\n".to_string());
let issue = first_issue(validate(&ticket_only));
assert_eq!(
issue.message,
"The message body is only a reference to a ticket number"
);
assert_eq!(issue.position, message_position(4, 1));
assert_contains_issue_output(
&issue,
"4 | Closes #123\n\
| ^^^^^^^^^^^ Add more detail about the change and why it was made",
);
}
#[test]
fn with_message_and_ticket_number() {
let commit = commit(
"Subject".to_string(),
"\nThis commit fixes a bug and it also closes #123\n".to_string(),
);
let issues = validate(&commit);
assert_eq!(issues, None);
}
#[test]
fn with_ticket_number_and_short_message() {
let message = commit(
"Subject".to_string(),
"\nFixes #1234\nShortmsg closes #123\n".to_string(),
);
let issue = first_issue(validate(&message));
assert_eq!(issue.message, "The message body is too short");
assert_eq!(issue.position, message_position(3, 1));
assert_contains_issue_output(
&issue,
"3 | Fixes #1234\n\
| ^^^^^^^^^^^\n\
4 | Shortmsg closes #123\n\
| ^^^^^^^^^^^^^^^^^^^^ Add more detail about the change and why it was made",
);
}
}