use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let _profile = env::var("PROFILE").unwrap_or_default();
if should_skip_installation() {
return;
}
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let git_dir = match find_git_dir(Path::new(&manifest_dir)) {
Some(dir) => dir,
None => {
return;
}
};
let config = read_config();
if config.no_install {
return;
}
let hooks_dir = if config.user_hooks {
let project_root = git_dir.parent().unwrap_or(Path::new("."));
let user_hooks_dir = project_root.join(".commitlint").join("hooks");
configure_git_hooks_path(&git_dir, &user_hooks_dir);
user_hooks_dir
} else {
git_dir.join("hooks")
};
if let Err(e) = install_commit_msg_hook(&hooks_dir, &config) {
println!("cargo:warning=Failed to install commit-msg hook: {}", e);
}
println!("cargo:rerun-if-changed=.commitlint/hooks/commit-msg");
println!("cargo:rerun-if-changed=.git/hooks/commit-msg");
println!("cargo:rerun-if-env-changed=COMMITLINT_NO_INSTALL");
}
#[derive(Default)]
struct Config {
no_install: bool,
user_hooks: bool,
run_cargo_fmt: bool,
run_cargo_clippy: bool,
run_cargo_test: bool,
}
fn should_skip_installation() -> bool {
if env::var("COMMITLINT_NO_INSTALL").is_ok() {
return true;
}
if env::var("CI").is_ok() && env::var("COMMITLINT_INSTALL_IN_CI").is_err() {
return true;
}
false
}
fn read_config() -> Config {
let mut config = Config::default();
if env::var("COMMITLINT_NO_INSTALL").is_ok() {
config.no_install = true;
}
if env::var("COMMITLINT_USER_HOOKS").is_ok() {
config.user_hooks = true;
}
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
let cargo_toml_path = Path::new(&manifest_dir).join("Cargo.toml");
if let Ok(content) = fs::read_to_string(&cargo_toml_path) {
let mut in_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[package.metadata.commitlint]" {
in_section = true;
continue;
}
if in_section {
if trimmed.starts_with('[') {
break; }
if trimmed.starts_with("no-install") && trimmed.contains("true") {
config.no_install = true;
}
if trimmed.starts_with("user-hooks") && trimmed.contains("true") {
config.user_hooks = true;
}
if trimmed.starts_with("run-cargo-fmt") && trimmed.contains("true") {
config.run_cargo_fmt = true;
}
if trimmed.starts_with("run-cargo-clippy") && trimmed.contains("true") {
config.run_cargo_clippy = true;
}
if trimmed.starts_with("run-cargo-test") && trimmed.contains("true") {
config.run_cargo_test = true;
}
}
}
}
config
}
fn find_git_dir(start: &Path) -> Option<PathBuf> {
let mut current = Some(start);
while let Some(dir) = current {
let git_dir = dir.join(".git");
if git_dir.exists() {
if git_dir.is_file() {
if let Ok(content) = fs::read_to_string(&git_dir) {
if let Some(path) = content.strip_prefix("gitdir: ") {
return Some(PathBuf::from(path.trim()));
}
}
}
return Some(git_dir);
}
current = dir.parent();
}
None
}
fn configure_git_hooks_path(git_dir: &Path, hooks_dir: &Path) {
let project_root = git_dir.parent().unwrap_or(Path::new("."));
let relative_path = hooks_dir
.strip_prefix(project_root)
.unwrap_or(hooks_dir)
.to_string_lossy();
let _ = Command::new("git")
.args(["config", "core.hooksPath", &relative_path])
.current_dir(project_root)
.output();
}
fn install_commit_msg_hook(hooks_dir: &Path, config: &Config) -> Result<(), String> {
fs::create_dir_all(hooks_dir)
.map_err(|e| format!("Failed to create hooks directory: {}", e))?;
let hook_path = hooks_dir.join("commit-msg");
if hook_path.exists() {
let content = fs::read_to_string(&hook_path).unwrap_or_default();
if content.contains("cargo-commitlint") || content.contains("cargo commitlint") {
return Ok(());
}
let updated_content = append_to_existing_hook(&content);
fs::write(&hook_path, updated_content)
.map_err(|e| format!("Failed to update hook: {}", e))?;
} else {
let hook_content = generate_hook_script(config);
fs::write(&hook_path, hook_content)
.map_err(|e| format!("Failed to write hook: {}", e))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path)
.map_err(|e| format!("Failed to get hook metadata: {}", e))?
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms)
.map_err(|e| format!("Failed to set hook permissions: {}", e))?;
}
println!("cargo:warning=cargo-commitlint: Installed commit-msg hook at {}", hook_path.display());
Ok(())
}
fn generate_hook_script(config: &Config) -> String {
let mut script = String::from(r#"#!/bin/sh
# Git commit-msg hook installed by cargo-commitlint
# This hook validates commit messages according to Conventional Commits specification
# https://github.com/pegasusheavy/cargo-commitlint
#
# To disable this hook, set COMMITLINT_SKIP=1 environment variable
COMMIT_MSG_FILE="$1"
# Skip if COMMITLINT_SKIP is set
if [ -n "$COMMITLINT_SKIP" ]; then
exit 0
fi
"#);
if config.run_cargo_fmt {
script.push_str(r#"
# Run cargo fmt check
if command -v cargo >/dev/null 2>&1; then
cargo fmt --check || {
echo "cargo fmt check failed. Run 'cargo fmt' to fix formatting."
exit 1
}
fi
"#);
}
if config.run_cargo_clippy {
script.push_str(r#"
# Run cargo clippy
if command -v cargo >/dev/null 2>&1; then
cargo clippy --all-targets --all-features -- -D warnings || {
echo "cargo clippy found warnings/errors."
exit 1
}
fi
"#);
}
script.push_str(r#"
# Validate commit message with cargo-commitlint
if command -v cargo >/dev/null 2>&1; then
# Try cargo subcommand first
if cargo commitlint --version >/dev/null 2>&1; then
cargo commitlint check --edit "$COMMIT_MSG_FILE"
exit $?
fi
fi
# Try direct binary
if command -v cargo-commitlint >/dev/null 2>&1; then
cargo-commitlint check --edit "$COMMIT_MSG_FILE"
exit $?
fi
# cargo-commitlint not found, skip validation with warning
echo "Warning: cargo-commitlint not found in PATH. Skipping commit message validation."
echo "Install with: cargo install cargo-commitlint"
exit 0
"#);
script
}
fn append_to_existing_hook(existing: &str) -> String {
let mut result = existing.to_string();
if result.contains("cargo-commitlint") || result.contains("cargo commitlint") {
return result;
}
result.push_str(r#"
# ============================================================
# cargo-commitlint validation (appended automatically)
# ============================================================
if [ -z "$COMMITLINT_SKIP" ]; then
if command -v cargo >/dev/null 2>&1 && cargo commitlint --version >/dev/null 2>&1; then
cargo commitlint check --edit "$1" || exit $?
elif command -v cargo-commitlint >/dev/null 2>&1; then
cargo-commitlint check --edit "$1" || exit $?
fi
fi
"#);
result
}