prek 0.5.0

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{Context, Result};
use prek_consts::env_vars::EnvVars;
use prek_consts::prepend_paths;
use semver::Version;
use serde::Deserialize;
use serde_json::{Map, Value, json};
use tracing::debug;

use crate::cli::reporter::HookInstallReporter;
use crate::git::GitCommandExt;
use crate::hook::{Hook, InstallInfo, InstalledHook};
use crate::languages::{ExecutionEnvironment, LanguageBackend};
use crate::process::Cmd;
use crate::store::Store;

const COMPOSER_JSON: &str = "composer.json";
const COMPOSER_ENVS_TO_REMOVE: &[&str] = &[
    "COMPOSER",
    "COMPOSER_BIN_DIR",
    "COMPOSER_IGNORE_PLATFORM_REQ",
    "COMPOSER_IGNORE_PLATFORM_REQS",
    "COMPOSER_VENDOR_DIR",
];
const HOOK_PACKAGE_VERSION: &str = "dev-prek";

#[derive(Debug, Copy, Clone)]
pub(crate) struct Php;

#[derive(Debug, Deserialize)]
struct ComposerPackage {
    name: String,
}

async fn query_php_version(executable: &Path) -> Result<Version> {
    let output = Cmd::new(executable)
        .arg("-r")
        .arg("echo PHP_VERSION;")
        .check(true)
        .output()
        .await
        .context("Failed to query PHP version")?;
    parse_php_version(&String::from_utf8_lossy(&output.stdout))
}

fn parse_php_version(output: &str) -> Result<Version> {
    let version = output.trim();
    if let Ok(version) = Version::parse(version) {
        return Ok(version);
    }

    let suffix = version
        .find(|character: char| !character.is_ascii_digit() && character != '.')
        .with_context(|| format!("Failed to parse PHP version `{version}`"))?;
    let normalized = format!("{}-{}", &version[..suffix], &version[suffix..]);
    Version::parse(&normalized).with_context(|| format!("Failed to parse PHP version `{version}`"))
}

async fn composer_package_name(repo_path: &Path) -> Result<String> {
    let manifest_path = repo_path.join(COMPOSER_JSON);
    let manifest = fs_err::tokio::read_to_string(&manifest_path)
        .await
        .with_context(|| {
            format!(
                "PHP hook repository must contain `{}`",
                manifest_path.display()
            )
        })?;
    let package: ComposerPackage = serde_json::from_str(&manifest)
        .with_context(|| format!("Failed to parse `{}`", manifest_path.display()))?;

    if package.name.trim().is_empty() {
        anyhow::bail!(
            "Composer package name in `{}` is empty",
            manifest_path.display()
        );
    }

    Ok(package.name)
}

fn composer_manifest(repo: Option<(&Path, &str)>) -> Result<Value> {
    let mut manifest = json!({
        "config": {
            "bin-dir": "bin",
            "vendor-dir": "vendor",
        },
    });

    if let Some((repo_path, package_name)) = repo {
        let repo_path = repo_path.to_str().with_context(|| {
            format!(
                "PHP hook repository path is not UTF-8: {}",
                repo_path.display()
            )
        })?;
        let mut versions = Map::new();
        versions.insert(
            package_name.to_string(),
            Value::String(HOOK_PACKAGE_VERSION.to_string()),
        );
        manifest["repositories"] = json!([{
            "type": "path",
            "url": repo_path,
            "options": {
                "symlink": false,
                "versions": versions,
            },
        }]);
    }

    Ok(manifest)
}

fn bin_dir(env_path: &Path) -> PathBuf {
    env_path.join("bin")
}

fn composer_command(composer: &Path, env_path: &Path, path_env: &OsStr) -> Cmd {
    let mut command = Cmd::new(composer);
    command
        .arg("--no-interaction")
        .arg("--no-ansi")
        .arg("--working-dir")
        .arg(env_path)
        .env(EnvVars::PATH, path_env);
    for &key in COMPOSER_ENVS_TO_REMOVE {
        command.env_remove(key);
    }
    command
}

#[async_trait::async_trait(?Send)]
impl LanguageBackend for Php {
    async fn install(
        &self,
        store: &Store,
        hook: Arc<Hook>,
        install_cwd: &Path,
        reporter: &HookInstallReporter,
    ) -> Result<InstalledHook> {
        let progress = reporter.on_install_start(&hook);
        let mut info = InstallInfo::new(&hook, &store.hooks_dir())?;

        debug!(%hook, target = %info.env_path.display(), "Installing PHP environment");

        let php = which::which("php")
            .context("Failed to locate php executable. Is PHP installed and available in PATH?")?;
        let php_version = query_php_version(&php).await?;
        info.with_language_version(php_version).with_toolchain(php);

        // Composer only creates bin proxies for dependencies, so install remote hooks as
        // mirrored path packages instead of running `composer install` in the hook repository.
        let repo_package = if let Some(repo_path) = hook.repo_path() {
            Some((repo_path, composer_package_name(repo_path).await?))
        } else {
            None
        };

        let mut dependencies = Vec::with_capacity(
            hook.additional_dependencies.len() + usize::from(repo_package.is_some()),
        );
        if let Some((_, package_name)) = &repo_package {
            dependencies.push(format!("{package_name}:{HOOK_PACKAGE_VERSION}"));
        }
        dependencies.extend(hook.additional_dependencies.iter().cloned());

        if !dependencies.is_empty() {
            let composer = which::which("composer").context(
                "Failed to locate composer executable. Is Composer installed and available in PATH?",
            )?;
            let manifest = composer_manifest(
                repo_package
                    .as_ref()
                    .map(|(repo_path, package_name)| (*repo_path, package_name.as_str())),
            )?;
            fs_err::tokio::write(
                info.env_path.join(COMPOSER_JSON),
                serde_json::to_vec_pretty(&manifest)?,
            )
            .await?;

            let php_bin = info
                .toolchain
                .parent()
                .context("PHP executable must have a parent directory")?;
            let path_env = prepend_paths(&[&bin_dir(&info.env_path), php_bin])
                .context("Failed to join PATH")?;

            composer_command(&composer, &info.env_path, &path_env)
                .current_dir(install_cwd)
                .arg("require")
                .arg("--no-progress")
                .arg("--")
                .args(dependencies)
                .sanitize_git_repo_env()
                .check(true)
                .output()
                .await
                .context("Failed to install PHP dependencies with Composer")?;

            composer_command(&composer, &info.env_path, &path_env)
                .current_dir(install_cwd)
                .arg("check-platform-reqs")
                .check(true)
                .output()
                .await
                .context("Failed to verify PHP platform requirements with Composer")?;
        }

        info.persist_env_path();
        reporter.on_install_complete(progress);

        Ok(InstalledHook::Installed {
            hook,
            info: Arc::new(info),
        })
    }

    async fn check_health(&self, info: &InstallInfo) -> Result<()> {
        let current_version = query_php_version(&info.toolchain)
            .await
            .context("Failed to query current PHP version")?;

        if current_version != info.language_version {
            anyhow::bail!(
                "PHP version mismatch: expected `{}`, found `{}`",
                info.language_version,
                current_version
            );
        }

        Ok(())
    }

    fn execution_environment(
        &self,
        _store: &Store,
        hook: &InstalledHook,
    ) -> Result<ExecutionEnvironment> {
        let env_path = hook.env_path().expect("PHP must have env path");
        let php_bin = hook
            .toolchain_dir()
            .expect("PHP executable must have a parent directory");
        let path_env =
            prepend_paths(&[&bin_dir(env_path), php_bin]).context("Failed to join PATH")?;

        let mut environment = ExecutionEnvironment::new();
        environment.set_path(&path_env);
        Ok(environment)
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{composer_manifest, parse_php_version};

    #[test]
    fn parse_php_version_accepts_release_version() {
        let version = parse_php_version("8.4.8\n").unwrap();
        assert_eq!(version.to_string(), "8.4.8");
    }

    #[test]
    fn parse_php_version_normalizes_prerelease_version() {
        let version = parse_php_version("8.5.0RC1").unwrap();
        assert_eq!(version.to_string(), "8.5.0-RC1");
    }

    #[test]
    fn composer_manifest_configures_remote_package_as_mirrored_path_repository() {
        let manifest = composer_manifest(Some((Path::new("/tmp/hook"), "example/hook"))).unwrap();

        assert_eq!(
            manifest,
            serde_json::json!({
                "config": {
                    "bin-dir": "bin",
                    "vendor-dir": "vendor",
                },
                "repositories": [{
                    "type": "path",
                    "url": "/tmp/hook",
                    "options": {
                        "symlink": false,
                        "versions": {"example/hook": "dev-prek"},
                    },
                }],
            })
        );
    }
}