mamoru 0.1.2

A blazing-fast Git hook spell checker that flags typos in commit messages using finite state transducers.
use std::{
    collections::HashSet,
    fs,
    path::{Path, PathBuf},
};

use clap::Parser;
use fuzzies::Dictionary;

#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default)]
pub(crate) enum Format {
    Silent,
    Brief,
    #[default]
    Long,
}

#[derive(Debug, Parser)]
#[command(rename_all = "kebab_case")]
#[command(about, author, version)]
pub(crate) struct Args {
    #[arg(long, help = "Install mamoru automatically into .git/hooks/")]
    pub(crate) init: bool,

    #[arg(
        short,
        long,
        requires = "init",
        help = "Overwrite existing hook during init"
    )]
    pub(crate) force: bool,

    #[arg(short, long, help = "Uninstall mamoru")]
    pub(crate) uninstall: bool,

    #[arg(long, help = "Only use ASCII characters for output")]
    pub(crate) ascii_only: bool,

    #[arg(index = 1, default_value = ".git/COMMIT_EDITMSG")]
    pub(crate) path: PathBuf,

    #[arg(long, ignore_case = true, value_enum, default_value_t = Format::Long)]
    pub(crate) format: Format,
}

pub fn initialization(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
    let dir = Path::new(".git").join("hooks");

    let path = dir.join("commit-msg");

    if !dir.exists() {
        fs::create_dir_all(&dir)?;
    }

    if path.exists() && !args.force {
        return Err(format!(
            "A hook already exists at `{}`. Use `--force` to overwrite it.",
            path.display()
        )
        .into());
    }

    let exe = std::env::current_exe()?;
    let exe_path_str = exe.to_string_lossy();

    let script = format!(
        "#!/bin/sh\n# Generated by mamoru\n\"{}\" \"$@\"\n",
        exe_path_str
    );

    fs::write(&path, script)?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = fs::metadata(&path)?.permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&path, perms)?;
    }

    println!("Mamoru installed successfully!");
    Ok(())
}

pub fn uninstall() -> Result<(), Box<dyn std::error::Error>> {
    let dir = Path::new(".git").join("hooks");

    let path = dir.join("commit-msg");

    if !path.exists() {
        println!("Mamoru not found");
        return Ok(());
    }

    if path.exists() {
        let content = fs::read_to_string(&path)?;
        let check = content.contains("# Generated by mamoru");

        if check {
            fs::remove_file(path)?;
            println!("Successfully removed mamoru. We'll miss you!")
        } else {
            return Err("The existing commit-msg hook was not created by mamoru. Aborting to prevent overwriting custom hooks.".into());
        }
    }

    Ok(())
}

pub fn check_commit(
    path: &Path,
    dict: &Dictionary,
    format: Format,
    ascii_only: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    if !path.exists() {
        return Err(format!(
            "Commit message file not found at `{}`. Are you in the middle of a git commit?",
            path.display()
        )
        .into());
    }

    let content = std::fs::read_to_string(path)?;

    const GIT_SCISSORS: &str = "# ------------------------ >8 ------------------------";

    let lines: Vec<&str> = content
        .lines()
        .map(|line| line.trim())
        .take_while(|line| !line.starts_with(GIT_SCISSORS))
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .collect();

    let unique_words: HashSet<String> = lines
        .into_iter()
        .flat_map(|line| line.split_whitespace())
        .filter(|word| !word.starts_with("http://") && !word.starts_with("https://"))
        .flat_map(|line| line.split(|c: char| !c.is_alphabetic() && c != '\''))
        .filter(|word| !word.is_empty())
        .map(|word| word.trim_matches('\'').to_lowercase())
        .collect();

    let mut typos = Vec::new();

    for word in unique_words {
        if !dict.contains(&word) {
            let suggestions = dict.search(&word).distance(2).limit(3).execute()?;

            typos.push((word, suggestions));
        }
    }

    if !typos.is_empty() {
        match format {
            Format::Silent => {
                return Err("".into());
            }
            Format::Brief => {
                let words: Vec<&str> = typos.iter().map(|(word, _)| word.as_str()).collect();
                eprintln!("Commit blocked! Typos found: {}", words.join(", "));
            }
            Format::Long => {
                let bullet = if ascii_only { '-' } else { '' };
                eprintln!("Commit blocked! Typos found in commit message:\n");
                for (word, suggestions) in typos {
                    let clean_suggestions: Vec<String> =
                        suggestions.into_iter().map(|s| s.key).collect();

                    if clean_suggestions.is_empty() {
                        eprintln!("  {} \"{}\" -> No close suggestions found.", bullet, word);
                    } else {
                        eprintln!(
                            "  {} \"{}\" -> Did you mean: {}?",
                            bullet,
                            word,
                            clean_suggestions.join(", ")
                        );
                    }
                }
                eprintln!();
            }
        }

        return Err("Please fix the spelling errors above.".into());
    }

    Ok(())
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn verify_app() {
        use clap::CommandFactory;
        Args::command().debug_assert();
    }
}