convco 0.6.3

Conventional commit tools
use std::{
    cmp::Ordering,
    fmt::{self, Display, Formatter},
    io::{stdin, Read},
};

use conventional::Config;
use git2::Repository;

use crate::{
    cli::CheckCommand,
    cmd::Command,
    conventional,
    git::{filter_merge_commits, filter_revert_commits},
    strip::Strip,
    Error,
};

fn print_fail(msg: &str, short_id: &str, e: impl Display) -> bool {
    let first_line = msg.lines().next().unwrap_or("");
    let short_msg: String = first_line.chars().take(40).collect();
    if first_line.len() > 40 {
        println!("FAIL  {}  {}  {}...", short_id, e, short_msg)
    } else {
        println!("FAIL  {}  {}  {}", short_id, e, short_msg)
    }
    false
}

struct TypeErrorWithSimilaritySuggestions<'a, 'b> {
    valid_types: &'a [String],
    wrong_type: &'b str,
}

impl Display for TypeErrorWithSimilaritySuggestions<'_, '_> {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let Self {
            valid_types,
            wrong_type,
        } = self;

        f.write_fmt(format_args!("wrong type: {wrong_type}"))?;
        if let Some((suggestion, _)) = valid_types
            .iter()
            .map(|s| (s, strsim::jaro_winkler(wrong_type, s)))
            .min_by(|(_, a), (_, b)| b.partial_cmp(a).unwrap_or(Ordering::Equal))
        {
            f.write_fmt(format_args!(", did you mean `{suggestion}`"))?;
        }

        Ok(())
    }
}

fn print_wrong_type(
    msg: &str,
    short_id: &str,
    commit_type: String,
    valid_types: &[String],
) -> bool {
    print_fail(
        msg,
        short_id,
        TypeErrorWithSimilaritySuggestions {
            wrong_type: &commit_type,
            valid_types,
        },
    )
}

fn print_check(
    msg: &str,
    short_id: &str,
    parser: &conventional::CommitParser,
    types: &[String],
) -> bool {
    let msg_parsed = parser.parse(msg);

    match msg_parsed {
        Err(e) => print_fail(msg, short_id, Error::from(e)),
        Ok(commit) if !types.contains(&commit.r#type) => {
            print_wrong_type(msg, short_id, commit.r#type, types)
        }
        _ => true,
    }
}

impl Command for CheckCommand {
    fn exec(&self, mut config: Config) -> anyhow::Result<()> {
        if self.merges {
            config.merges = true;
        }
        if self.first_parent {
            config.first_parent = true;
        }

        let mut total = 0;
        let mut fail = 0;

        let parser = conventional::CommitParser::builder()
            .scope_regex(config.scope_regex)
            .strip_regex(config.strip_regex)
            .build();
        let types: Vec<String> = config
            .types
            .iter()
            .map(|ty| ty.r#type.as_str())
            .map(String::from)
            .collect();

        let Config { merges, .. } = config;

        if self.from_stdin {
            let mut stdin = stdin().lock();
            let mut commit_msg = String::new();
            stdin.read_to_string(&mut commit_msg)?;
            if self.strip {
                commit_msg = commit_msg.strip();
            }
            let is_conventional = print_check(commit_msg.as_str(), "-", &parser, &types);
            match is_conventional {
                true => return Ok(()),
                false => return Err(Error::Check)?,
            }
        }

        let repo = Repository::open_from_env()?;
        let mut revwalk = repo.revwalk()?;
        if config.first_parent {
            revwalk.simplify_first_parent()?;
        }
        let rev = match self.rev.as_ref() {
            Some(rev) if !rev.is_empty() => rev.as_str(),
            _ => "HEAD",
        };

        if rev.contains("..") {
            revwalk.push_range(rev)?;
        } else {
            let oid = repo.revparse_single(rev)?.id();
            revwalk.push(oid)?;
        }

        for commit in revwalk
            .flatten()
            .flat_map(|oid| repo.find_commit(oid).ok())
            .filter(|commit| filter_merge_commits(commit, merges))
            .filter(|commit| filter_revert_commits(commit, self.ignore_reverts))
            .take(self.number.unwrap_or(std::usize::MAX))
        {
            total += 1;
            let msg = std::str::from_utf8(commit.message_bytes()).expect("valid utf-8 message");
            let short_id = commit.as_object().short_id().unwrap();
            let short_id = short_id.as_str().expect("short id");
            fail += u32::from(!print_check(msg, short_id, &parser, &types));
        }
        if fail == 0 {
            match total {
                0 => println!("no commits checked"),
                1 => println!("no errors in {} commit", total),
                _ => println!("no errors in {} commits", total),
            }
            Ok(())
        } else {
            println!("\n{}/{} failed", fail, total);
            Err(Error::Check)?
        }
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_suggestions() {
        let output = super::TypeErrorWithSimilaritySuggestions {
            wrong_type: "tests",
            valid_types: &[
                "feat", "fix", "build", "chore", "ci", "docs", "style", "refactor", "perf", "test",
            ]
            .map(|s| s.to_string()),
        }
        .to_string();

        assert_eq!(output, "wrong type: tests, did you mean `test`");
    }
}