use core::ops::Range;
use crate::commit::Commit;
use crate::issue::{Context, Issue, Position};
use crate::rule::Rule;
use crate::rule::RuleValidator;
use crate::utils::character_count_for_bytes_index;
use crate::rules::CONTAINS_FIX_TICKET_OR_TICKET_REFERENCE;
pub struct SubjectTicketNumber {}
impl SubjectTicketNumber {
pub fn new() -> Self {
Self {}
}
}
impl RuleValidator<Commit> for SubjectTicketNumber {
fn validate(&self, commit: &Commit) -> Option<Vec<Issue>> {
let mut issues = vec![];
let subject = &commit.subject.to_string();
for captures in CONTAINS_FIX_TICKET_OR_TICKET_REFERENCE.captures_iter(subject) {
let capture = match captures.name("match") {
Some(capture) => capture,
None => {
error!(
"SubjectTicketNumber: Unable to fetch ticket number match from subject."
);
continue;
}
};
if !is_standalone_ticket_reference(subject, capture.start()) {
continue;
}
let keyword = if captures.name("keyword").is_none() {
Some("Fix ")
} else {
None
};
issues.push(add_subject_ticket_number_error(commit, capture, keyword));
break;
}
if issues.is_empty() {
None
} else {
Some(issues)
}
}
}
fn is_standalone_ticket_reference(subject: &str, start: usize) -> bool {
start == 0
|| subject[..start]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace())
}
fn add_subject_ticket_number_error(
commit: &Commit,
capture: regex::Match,
keyword: Option<&str>,
) -> Issue {
let subject = commit.subject.to_string();
let line_count = commit.message.lines().count();
let base_line_count = if line_count == 0 { 3 } else { line_count + 2 };
let keyword_label = keyword.unwrap_or("");
let context = vec![
Context::subject_removal_suggestion(
subject,
capture.range(),
"Remove the ticket number from the subject".to_string(),
),
Context::gap(),
Context::message_line(base_line_count, "".to_string()),
Context::message_line_addition(
base_line_count + 1,
format!("{}{}", keyword_label, capture.as_str()),
Range {
start: 0,
end: capture.range().len() + keyword_label.len(),
},
"Move the ticket number to the message body".to_string(),
),
];
Issue::error(
Rule::SubjectTicketNumber,
"The subject contains a ticket number".to_string(),
Position::Subject {
line: 1,
column: character_count_for_bytes_index(&commit.subject, capture.start()),
},
context,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test::*;
fn validate(commit: &Commit) -> Option<Vec<Issue>> {
SubjectTicketNumber::new().validate(commit)
}
fn assert_subject_as_valid(subject: &str) {
assert_eq!(
validate(&commit(subject, "")),
None,
"Subject not valid: {}",
subject
);
}
fn assert_subject_as_invalid(subject: &str) {
let issues = validate(&commit(subject, ""));
assert!(issues.is_some(), "No issues found for: {:?}", subject);
}
#[test]
fn valid_subjects() {
let subjects = vec![
"This is a normal commit",
"Fix #", "Fix #a123",
"Fix ##123",
"Fix !",
"Fix !a123",
"Fix !!123",
"Fix /123", "Fix repo/123", "Fix repo#123", "Fix repo!123", "Fix https://website.om/org/repo/issues#123", "Fix https://website.om/org/repo/issues!123", "Change A-1 config",
"Change A-12 config",
"Fix abc-123", "Use utf-8 encoding",
"Add utf-16le support",
"Change abc-1234 config",
"foo-bar-99 baz",
];
for subject in subjects {
assert_subject_as_valid(subject);
}
}
#[test]
fn with_ticket_numbers() {
let ticket_only_subjects = vec![
"JI-1",
"JI-12",
"JI-1234567890",
"JIR-1",
"JIR-12",
"JIR-1234567890",
"JIRA-12",
"JIRA-123",
"JIRA-1234",
"JIRA-1234567890",
"Fix JIRA-1234 lorem",
];
for subject in ticket_only_subjects {
assert_subject_as_invalid(subject);
}
}
#[test]
fn with_keywords() {
let invalid_subjects = vec![
"Fix {}1234",
"Fixed {}1234",
"Fixes {}1234",
"Fixing {}1234",
"Fix {}1234 lorem",
"Fix: {}1234 lorem",
"Fix my-org/repo{}1234 lorem",
"Commit fixes {}1234",
"Close {}1234",
"Closed {}1234",
"Closes {}1234",
"Closing {}1234",
"Close {}1234 lorem",
"Close: {}1234 lorem",
"Commit closes {}1234",
"Resolve {}1234",
"Resolved {}1234",
"Resolves {}1234",
"Resolving {}1234",
"Resolve {}1234 lorem",
"Resolve: {}1234 lorem",
"Commit resolves {}1234",
"Implement {}1234",
"Implemented {}1234",
"Implements {}1234",
"Implementing {}1234",
"Implement {}1234 lorem",
"Implement: {}1234 lorem",
"Commit implements {}1234",
];
let invalid_issue_subjects: Vec<String> = invalid_subjects
.iter()
.map(|s| s.replace("{}", "#"))
.collect();
for subject in invalid_issue_subjects {
assert_subject_as_invalid(subject.as_str());
}
let invalid_merge_request_subjects: Vec<String> = invalid_subjects
.iter()
.map(|s| s.replace("{}", "!"))
.collect();
for subject in invalid_merge_request_subjects {
assert_subject_as_invalid(subject.as_str());
}
}
#[test]
fn jira_ticket_number() {
let issue = first_issue(validate(&commit("Fix JIRA-123 about email validation", "")));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(1));
assert_contains_issue_output(
&issue,
"1 | Fix JIRA-123 about email validation\n\
| ------------ Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fix JIRA-123\n\
| ++++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn jira_ticket_number_unicode() {
let issue = first_issue(validate(&commit(
"Fix ❤\u{fe0f} JIRA-123 about email validation",
"",
)));
assert_eq!(issue.position, subject_position(7));
assert_contains_issue_output(
&issue,
"1 | Fix ❤️ JIRA-123 about email validation\n\
| -------- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fix JIRA-123\n\
| ++++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn jira_ticket_number_without_keyword() {
let issue = first_issue(validate(&commit("JIRA-123 about email validation", "")));
assert_eq!(issue.position, subject_position(1));
assert_contains_issue_output(
&issue,
"1 | JIRA-123 about email validation\n\
| -------- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fix JIRA-123\n\
| ++++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn fix_ticket_number() {
let issue = first_issue(validate(&commit(
"Email validation: Fixes #123 for good",
"",
)));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(19));
assert_contains_issue_output(
&issue,
"1 | Email validation: Fixes #123 for good\n\
| ---------- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fixes #123\n\
| ++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn ticket_number_without_keyword() {
let issue = first_issue(validate(&commit("#123: fix it for good", "")));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(1));
assert_contains_issue_output(
&issue,
"1 | #123: fix it for good\n\
| ---- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fix #123\n\
| ++++++++ Move the ticket number to the message body",
);
}
#[test]
fn ticket_number_at_end() {
let issue = first_issue(validate(&commit("Something related to issue #123", "")));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(28));
assert_contains_issue_output(
&issue,
"1 | Something related to issue #123\n\
| ---- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Fix #123\n\
| ++++++++ Move the ticket number to the message body",
);
}
#[test]
fn fix_ticket_number_unicode() {
let issue = first_issue(validate(&commit("Email validation: Fixes #123", "")));
assert_eq!(issue.position, subject_position(19));
}
#[test]
fn fix_ticket_number_link_shorthand() {
let issue = first_issue(validate(&commit(
"Email validation: Closed org/repo#123 for good",
"",
)));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(19));
assert_contains_issue_output(
&issue,
"1 | Email validation: Closed org/repo#123 for good\n\
| ------------------- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Closed org/repo#123\n\
| +++++++++++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn fix_ticket_number_link() {
let issue = first_issue(validate(&commit(
"Email validation: Closes https://website.com:80/org/repo/issues/123 for good",
"",
)));
assert_eq!(issue.message, "The subject contains a ticket number");
assert_eq!(issue.position, subject_position(19));
assert_contains_issue_output(
&issue,
"1 | Email validation: Closes https://website.com:80/org/repo/issues/123 for good\n\
| ------------------------------------------------- Remove the ticket number from the subject\n\
~~~\n\
3 | \n\
4 | Closes https://website.com:80/org/repo/issues/123\n\
| +++++++++++++++++++++++++++++++++++++++++++++++++ Move the ticket number to the message body",
);
}
#[test]
fn multiple_issues() {
let issues = validate(&commit("Fix #123 JIRA-123", "")).expect("No issues");
assert_eq!(issues.len(), 1);
}
}