lintje 0.4.0

Lintje is an opinionated linter for Git.
use crate::rule::{Rule, Violation};
use crate::utils::is_punctuation;
use regex::{Regex, RegexBuilder};

lazy_static! {
    static ref BRANCH_WITH_TICKET_NUMBER: Regex = {
        let mut tempregex = RegexBuilder::new(r"^(\w+[-_/])?\d+([-_/]\w+)?([-_/]\w+)?");
        tempregex.case_insensitive(true);
        tempregex.multi_line(false);
        tempregex.build().unwrap()
    };
    static ref BRANCH_WITH_CLICHE: Regex = {
        let mut tempregex = RegexBuilder::new(
            r"^(wip|fix(es|ed|ing)?|add(s|ed|ing)?|(updat|chang|remov|delet)(e|es|ed|ing))([-_/]+\w+)?$",
        );
        tempregex.case_insensitive(true);
        tempregex.multi_line(false);
        tempregex.build().unwrap()
    };
}

#[derive(Debug)]
pub struct Branch {
    pub name: String,
    pub violations: Vec<Violation>,
}

impl Branch {
    pub fn new(name: String) -> Self {
        Self {
            name,
            violations: Vec::<Violation>::new(),
        }
    }

    pub fn is_valid(&self) -> bool {
        self.violations.is_empty()
    }

    pub fn validate(&mut self) {
        self.validate_length();
        self.validate_ticket_number();
        self.validate_punctuation();
        self.validate_cliche();
    }

    fn validate_length(&mut self) {
        let length = self.name.chars().count();
        if length < 4 {
            self.add_violation(
                Rule::BranchNameLength,
                format!(
                    "Branch name is too short: {} characters. Describe the branch in more detail.",
                    length
                ),
            )
        }
    }

    fn validate_ticket_number(&mut self) {
        let name = &self.name;
        if let Some(captures) = BRANCH_WITH_TICKET_NUMBER.captures(name) {
            let valid = match (captures.get(1), captures.get(2), captures.get(3)) {
                (None, None, _) => false,
                (Some(_prefix), None, _) => false,
                (None, Some(_suffix), None) => false,
                (None, Some(_suffix), Some(_suffix_more)) => true,
                (Some(_prefix), Some(_suffix), _) => true,
            };
            if !valid {
                self.add_violation(
                    Rule::BranchNameTicketNumber,
                    "Remove the ticket number from the branch name or expand the branch name with more details."
                    .to_string(),
                )
            }
        }
    }

    fn validate_punctuation(&mut self) {
        match &self.name.chars().next() {
            Some(character) => {
                if is_punctuation(&character) {
                    self.add_violation(
                        Rule::BranchNamePunctuation,
                        format!(
                            "Remove punctuation from the start of the branch name: {}",
                            character
                        ),
                    )
                }
            }
            None => {
                error!(
                    "BranchNamePunctuation validation failure: No first character found of branch name."
                )
            }
        }

        match &self.name.chars().last() {
            Some(character) => {
                if is_punctuation(&character) {
                    self.add_violation(
                        Rule::BranchNamePunctuation,
                        format!(
                            "Remove punctuation from the end of the branch name: {}",
                            character
                        ),
                    )
                }
            }
            None => {
                error!(
                    "BranchNamePunctuation validation failure: No last character found of branch name."
                )
            }
        }
    }

    fn validate_cliche(&mut self) {
        let branch = &self.name.to_lowercase();
        if BRANCH_WITH_CLICHE.is_match(branch) {
            self.add_violation(
                Rule::BranchNameCliche,
                "Reword the branch name to describe the change in more detail.".to_string(),
            )
        }
    }

    fn add_violation(&mut self, rule: Rule, message: String) {
        self.violations.push(Violation { rule, message })
    }
}

#[cfg(test)]
mod tests {
    use super::{Branch, Rule, Violation};

    fn validated_branch(name: String) -> Branch {
        let mut branch = Branch::new(name);
        branch.validate();
        branch
    }

    fn assert_branch_valid_for(branch: Branch, rule: &Rule) {
        assert!(
            !has_violation(&branch.violations, rule),
            "Branch was not considered valid: {:?}",
            branch
        );
    }

    fn assert_branch_invalid_for(branch: Branch, rule: &Rule) {
        assert!(
            has_violation(&branch.violations, rule),
            "Branch was not considered invalid: {:?}",
            branch
        );
    }

    fn assert_branch_name_as_valid<S: AsRef<str>>(name: S, rule: &Rule) {
        let branch = validated_branch(name.as_ref().to_string());
        assert_branch_valid_for(branch, rule);
    }

    fn assert_branch_name_as_invalid<S: AsRef<str>>(name: S, rule: &Rule) {
        let branch = validated_branch(name.as_ref().to_string());
        assert_branch_invalid_for(branch, rule);
    }

    fn assert_branch_names_as_valid<S: AsRef<str>>(names: Vec<S>, rule: &Rule) {
        for name in names {
            assert_branch_name_as_valid(name, rule)
        }
    }

    fn assert_branch_names_as_invalid<S: AsRef<str>>(names: Vec<S>, rule: &Rule) {
        for name in names {
            assert_branch_name_as_invalid(name, rule)
        }
    }

    fn has_violation(violations: &Vec<Violation>, rule: &Rule) -> bool {
        violations.iter().any(|v| &v.rule == rule)
    }

    #[test]
    fn test_validate_name_length() {
        let valid_names = vec![
            "abcd".to_string(),
            "-_/!".to_string(),
            "a".repeat(5),
            "a".repeat(50),
            "".repeat(4),
            "".repeat(4),
        ];
        assert_branch_names_as_valid(valid_names, &Rule::BranchNameLength);

        let invalid_names = vec!["", "a", "ab", "abc"];
        assert_branch_names_as_invalid(invalid_names, &Rule::BranchNameLength);
    }

    #[test]
    fn test_branch_ticket_number() {
        let valid_names = vec![
            "123-fix-bug",
            "123_fix-bug",
            "123/fix-bug",
            "123-add-feature",
            "fix-123-bug",
            "fix_123-bug",
            "fix/123-bug",
            "feature-123-cool",
            "add-feature-123",
            "add-feature-123-cool",
            "fix-bug",
        ];
        assert_branch_names_as_valid(valid_names, &Rule::BranchNameTicketNumber);

        let invalid_names = vec![
            "123",
            "123-FIX",
            "123-Fix",
            "123-fix",
            "123_fix",
            "123/fix",
            "123-feature",
            "FIX-123",
            "Fix-123",
            "fix-123",
            "fix_123",
            "fix/123",
            "feature-123",
            "JIRA-123",
        ];
        assert_branch_names_as_invalid(invalid_names, &Rule::BranchNameTicketNumber);
    }

    #[test]
    fn test_validate_punctuation() {
        let subjects = vec!["fix-test", "fix-あ-test"];
        assert_branch_names_as_valid(subjects, &Rule::BranchNamePunctuation);

        let invalid_subjects = vec![
            "fix.",
            "fix!",
            "fix?",
            "fix:",
            "fix-",
            "fix_",
            "fix/",
            "fix\'",
            "fix\"",
            "fix…",
            "fix⋯",
            ".fix",
            "!fix",
            "?fix",
            ":fix",
            "-fix",
            "_fix",
            "/fix",
            "…fix",
            "⋯fix",
            "[JIRA-123",
            "[bug-fix",
            "(feat-fix",
            "{fix-test",
            "|fix-test",
            "-fix-test",
            "+fix-test",
            "*fix-test",
            "%fix-test",
            "@fix-test",
        ];
        assert_branch_names_as_invalid(invalid_subjects, &Rule::BranchNamePunctuation);
    }

    #[test]
    fn test_validate_cliche() {
        let subjects = vec!["add-email-validation", "fix-brittle-test"];
        assert_branch_names_as_valid(subjects, &Rule::BranchNameCliche);

        let prefixes = vec![
            "wip", "fix", "fixes", "fixed", "fixing", "add", "adds", "added", "adding", "update",
            "updates", "updated", "updating", "change", "changes", "changed", "changing", "remove",
            "removes", "removed", "removing", "delete", "deletes", "deleted", "deleting",
        ];
        let mut invalid_subjects = vec![];
        for word in prefixes.iter() {
            let uppercase_word = word.to_uppercase();
            let mut chars = word.chars();
            let capitalized_word = match chars.next() {
                None => panic!("Could not capitalize word: {}", word),
                Some(letter) => letter.to_uppercase().collect::<String>() + chars.as_str(),
            };

            invalid_subjects.push(format!("{}", uppercase_word));
            invalid_subjects.push(format!("{}", capitalized_word));
            invalid_subjects.push(format!("{}", word));
            invalid_subjects.push(format!("{}-test", uppercase_word));
            invalid_subjects.push(format!("{}-issue", capitalized_word));
            invalid_subjects.push(format!("{}-bug", word));
            invalid_subjects.push(format!("{}-readme", word));
            invalid_subjects.push(format!("{}-something", word));
            invalid_subjects.push(format!("{}_something", word));
            invalid_subjects.push(format!("{}/something", word));
        }
        for subject in invalid_subjects {
            assert_branch_name_as_invalid(subject.as_str(), &Rule::BranchNameCliche);
        }
    }
}