mamoru 0.2.0

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, Subcommand};
use fuzzies::Dictionary;

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

#[derive(Debug, Parser)]
#[command(name = "mamoru", about, version)]
pub(crate) struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Debug, Subcommand)]
#[command(rename_all = "kebab_case")]
pub(crate) enum Commands {
    Init {
        #[arg(short, long)]
        force: bool,
    },

    Uninstall,

    Check {
        #[arg(index = 1, default_value = ".git/COMMIT_EDITMSG")]
        path: PathBuf,
        #[arg(long)]
        ascii_only: bool,
        #[arg(long, ignore_case = true, value_enum, default_value_t = Format::Long)]
        format: Format,
    },
}

pub fn initialization(force: bool) -> 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() && !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\"{}\" check \"$@\"\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(());
    }

    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 unique_words: HashSet<String> = content
        .lines()
        .map(|line| line.trim())
        .take_while(|line| !line.starts_with(GIT_SCISSORS))
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .flat_map(|line| line.split_whitespace())
        .filter(|word| !word.starts_with("http://") && !word.starts_with("https://"))
        .filter(|word| !word.chars().any(|c| c.is_ascii_digit()))
        .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;
        Cli::command().debug_assert();
    }
}