prek 0.4.13

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

use anyhow::{Context, Result};
use prek_consts::env_vars::EnvVars;
use prek_consts::prepend_paths;
use tracing::debug;

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

const PRE_COMMIT_CHANNEL_DIR: &str = ".pre-commit-channel";

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

fn channel_app_name(file_name: &str) -> &str {
    match file_name.rfind('.') {
        Some(0) | None => file_name,
        Some(index) if index + 1 == file_name.len() => file_name,
        Some(index) => &file_name[..index],
    }
}

fn collect_channel_apps(channel_dir: &Path) -> Result<Option<Vec<String>>> {
    let entries = match fs_err::read_dir(channel_dir) {
        Ok(entries) => entries,
        Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
            return Ok(None);
        }
        Err(err) => {
            return Err(err).with_context(|| format!("Failed to read `{}`", channel_dir.display()));
        }
    };

    let mut apps = entries
        .map(|entry| {
            let file_name = entry?.file_name();
            let file_name = file_name.to_string_lossy();
            Ok(channel_app_name(&file_name).to_string())
        })
        .collect::<Result<Vec<_>>>()?;
    apps.sort_unstable();
    Ok(Some(apps))
}

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

        let dependencies = &hook.additional_dependencies;

        let cs = which::which("cs")
            .or_else(|_| which::which("coursier"))
            .context(
                "Coursier hooks require system-installed `cs` or `coursier` executables in PATH",
            )?;
        let mut info = InstallInfo::new(&hook, &store.hooks_dir())?;

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

        fs_err::tokio::create_dir_all(&info.env_path).await?;
        let coursier_cache = store.cache_path(CacheBucket::Coursier);
        fs_err::tokio::create_dir_all(&coursier_cache).await?;

        let path_env = prepend_paths(&[&info.env_path]).context("Failed to join PATH")?;
        let mut has_channel_apps = false;

        if let Some(repo_path) = hook.repo_path() {
            let channel_dir = repo_path.join(PRE_COMMIT_CHANNEL_DIR);
            if let Some(channel_apps) = collect_channel_apps(&channel_dir)? {
                has_channel_apps = true;
                for app in channel_apps {
                    Cmd::new(&cs)
                        .current_dir(repo_path)
                        .arg("install")
                        .arg("--dir")
                        .arg(&info.env_path)
                        .arg("--default-channels=false")
                        .arg("--channel")
                        .arg(&channel_dir)
                        .arg(&app)
                        .env(EnvVars::PATH, &path_env)
                        .env(EnvVars::COURSIER_CACHE, &coursier_cache)
                        .check(true)
                        .output()
                        .await
                        .with_context(|| format!("Failed to install coursier app `{app}`"))?;
                }
            }
        }

        if !dependencies.is_empty() {
            let mut fetch_cmd = Cmd::new(&cs);
            fetch_cmd
                .arg("fetch")
                .args(dependencies)
                .env(EnvVars::PATH, &path_env)
                .env(EnvVars::COURSIER_CACHE, &coursier_cache);
            if let Some(repo_path) = hook.repo_path() {
                fetch_cmd.current_dir(repo_path);
            }
            fetch_cmd.check(true).output().await.with_context(|| {
                format!("Failed to fetch coursier app `{}`", dependencies.join(" "))
            })?;

            let mut install_cmd = Cmd::new(&cs);
            install_cmd
                .arg("install")
                .arg("--dir")
                .arg(&info.env_path)
                .args(dependencies)
                .env(EnvVars::PATH, path_env)
                .env(EnvVars::COURSIER_CACHE, &coursier_cache);
            if let Some(repo_path) = hook.repo_path() {
                install_cmd.current_dir(repo_path);
            }
            install_cmd.check(true).output().await.with_context(|| {
                format!(
                    "Failed to install coursier app `{}`",
                    dependencies.join(" ")
                )
            })?;
        } else if !has_channel_apps {
            anyhow::bail!("expected `.pre-commit-channel` directory or `additional_dependencies`");
        }

        info.with_toolchain(cs);
        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<()> {
        Ok(())
    }

    fn execution_environment(
        &self,
        store: &Store,
        hook: &InstalledHook,
    ) -> Result<ExecutionEnvironment> {
        let env_path = hook.env_path().expect("Coursier must have env path");
        let path_env = prepend_paths(&[env_path]).context("Failed to join PATH")?;
        let coursier_cache = store.cache_path(CacheBucket::Coursier);

        let mut environment = ExecutionEnvironment::new();
        environment
            .set_path(&path_env)
            .env(EnvVars::COURSIER_CACHE, &coursier_cache);
        Ok(environment)
    }
}

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

    #[test]
    fn channel_app_name_drops_descriptor_extension() {
        assert_eq!(channel_app_name("scalafmt.json"), "scalafmt");
        assert_eq!(channel_app_name("foo.bar.json"), "foo.bar");
    }

    #[test]
    fn channel_app_name_keeps_dotfiles_and_trailing_dots() {
        assert_eq!(channel_app_name(".scalafmt"), ".scalafmt");
        assert_eq!(channel_app_name("scalafmt."), "scalafmt.");
    }
}