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();
}
}