prek 0.5.4

A fast Git hook manager written in Rust, designed as a drop-in alternative to pre-commit, reimagined.
use std::env::consts::EXE_EXTENSION;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::string::ToString;
use std::sync::{Arc, LazyLock};

use anyhow::{Context, Result};
use asyncband::once::OnceMap;
use itertools::Itertools;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use rustc_hash::FxBuildHasher;
use target_lexicon::{Architecture, HOST, OperatingSystem};
use tracing::{debug, trace, warn};

use crate::archive;
use crate::checksum::{digest_from_sha256sums, fetch_checksum};
use crate::fs::{LockedFile, is_executable};
use crate::http::{REQWEST_CLIENT, download_artifact};
use crate::languages::node::NodeRequest;
use crate::languages::node::version::NodeVersion;
use crate::languages::version::{ToolchainPolicy, ToolchainSource, find_system_executables};
use crate::process::Cmd;
use crate::store::Store;

#[derive(Debug)]
pub(crate) struct NodeResult {
    node: PathBuf,
    npm: PathBuf,
    version: Arc<NodeVersion>,
}

impl Display for NodeResult {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}@{}", self.node.display(), self.version)?;
        Ok(())
    }
}

/// Override the Node binary name for testing.
static NODE_BINARY_NAME: LazyLock<String> = LazyLock::new(|| {
    if let Ok(name) = EnvVars.var(EnvVars::PREK_INTERNAL__NODE_BINARY_NAME) {
        name
    } else {
        "node".to_string()
    }
});

impl NodeResult {
    pub(crate) fn from_dir(dir: &Path, version: NodeVersion) -> Self {
        let node = bin_dir(dir).join("node").with_extension(EXE_EXTENSION);
        let npm = bin_dir(dir)
            .join("npm")
            .with_extension(if cfg!(windows) { "cmd" } else { "" });
        Self {
            node,
            npm,
            version: Arc::new(version),
        }
    }

    pub(crate) async fn from_executables(node: PathBuf, npm: PathBuf) -> Result<Self> {
        let version = query_node_version_cached(&node).await?;
        Ok(Self { node, npm, version })
    }

    pub(crate) fn node(&self) -> &Path {
        &self.node
    }

    pub(crate) fn npm(&self) -> &Path {
        &self.npm
    }

    pub(crate) fn version(&self) -> &NodeVersion {
        &self.version
    }
}

// Canonical paths let hook environments backed by the same Node executable share one query.
static NODE_VERSION_CACHE: LazyLock<OnceMap<PathBuf, Arc<NodeVersion>, FxBuildHasher>> =
    LazyLock::new(|| OnceMap::with_hasher(FxBuildHasher));

async fn query_node_version(node: &Path) -> Result<NodeVersion> {
    // https://nodejs.org/api/process.html#processrelease
    let output = Cmd::new(node)
        .arg("-p")
        .arg("JSON.stringify({version: process.version, lts: process.release.lts || false})")
        .check(true)
        .output()
        .await?;
    serde_json::from_slice(&output.stdout).context("Failed to parse node version")
}

pub(crate) async fn query_node_version_cached(node: &Path) -> Result<Arc<NodeVersion>> {
    let node = fs_err::canonicalize(node).unwrap_or_else(|_| node.to_path_buf());
    NODE_VERSION_CACHE
        .try_compute(node.clone(), async move || {
            query_node_version(&node).await.map(Arc::new)
        })
        .await
}

pub(crate) struct NodeInstaller {
    root: PathBuf,
}

impl NodeInstaller {
    pub(crate) fn new(root: PathBuf) -> Self {
        Self { root }
    }

    /// Install a version of Node.js.
    pub(crate) async fn install(
        &self,
        store: &Store,
        request: &NodeRequest,
        policy: ToolchainPolicy,
    ) -> Result<NodeResult> {
        fs_err::tokio::create_dir_all(&self.root).await?;

        let _lock = LockedFile::acquire(self.root.join(".lock"), "node").await?;

        for &source in policy.search_order() {
            let result = match source {
                ToolchainSource::Managed => self.find_installed(request).ok(),
                ToolchainSource::System => self.find_system_node(request).await?,
            };
            if let Some(result) = result {
                trace!(%result, ?source, "Found node");
                return Ok(result);
            }
        }

        if !policy.allows_download() {
            anyhow::bail!("No suitable Node version found for toolchain policy: {policy}");
        }

        let resolved_version = self.resolve_version(request).await?;
        trace!(version = %resolved_version, "Downloading node");

        self.download(store, &resolved_version).await
    }

    /// Get the installed version of Node.js.
    fn find_installed(&self, req: &NodeRequest) -> Result<NodeResult> {
        let mut installed = fs_err::read_dir(&self.root)
            .ok()
            .into_iter()
            .flatten()
            .filter_map(|entry| match entry {
                Ok(entry) => Some(entry),
                Err(err) => {
                    warn!(?err, "Failed to read entry");
                    None
                }
            })
            .filter(|entry| entry.file_type().is_ok_and(|f| f.is_dir()))
            .filter_map(|entry| {
                let dir_name = entry.file_name();
                let version = NodeVersion::from_str(&dir_name.to_string_lossy()).ok()?;
                Some((version, entry.path()))
            })
            .sorted_unstable_by(|(a, _), (b, _)| a.version.cmp(&b.version))
            .rev();

        installed
            .find_map(|(v, path)| {
                if req.matches(&v) {
                    Some(NodeResult::from_dir(&path, v))
                } else {
                    None
                }
            })
            .context("No installed node version matches the request")
    }

    async fn resolve_version(&self, req: &NodeRequest) -> Result<NodeVersion> {
        // Latest versions come first, so we can find the latest matching version.
        let versions = self
            .list_remote_versions()
            .await
            .context("Failed to list remote versions")?;
        let version = versions
            .into_iter()
            .find(|version| req.matches(version))
            .context("Version not found on remote")?;
        Ok(version)
    }

    /// List all versions of Node.js available on the Node.js website.
    async fn list_remote_versions(&self) -> Result<Vec<NodeVersion>> {
        let url = "https://nodejs.org/dist/index.json";
        let versions: Vec<NodeVersion> = REQWEST_CLIENT.get(url).send().await?.json().await?;
        Ok(versions)
    }

    // TODO: support mirror?
    /// Install a specific version of Node.js.
    async fn download(&self, store: &Store, version: &NodeVersion) -> Result<NodeResult> {
        let mut arch = match HOST.architecture {
            Architecture::X86_32(_) => "x86",
            Architecture::X86_64 => "x64",
            Architecture::Aarch64(_) => "arm64",
            Architecture::Arm(_) => "armv7l",
            Architecture::S390x => "s390x",
            Architecture::Powerpc => "ppc64",
            Architecture::Powerpc64le => "ppc64le",
            _ => anyhow::bail!("Unsupported architecture"),
        };
        let os = match HOST.operating_system {
            OperatingSystem::Darwin(_) => "darwin",
            OperatingSystem::Linux => "linux",
            OperatingSystem::Windows => "win",
            OperatingSystem::Aix => "aix",
            _ => anyhow::bail!("Unsupported OS"),
        };
        if os == "darwin" && arch == "arm64" && version.major() < 16 {
            // Node.js 16 and later are required for arm64 on macOS.
            arch = "x64";
        }
        let ext = if cfg!(windows) { "zip" } else { "tar.xz" };

        let filename = format!("node-v{}-{os}-{arch}.{ext}", version.version());
        let url = format!("https://nodejs.org/dist/v{}/{filename}", version.version());
        let checksum_url = format!(
            "https://nodejs.org/dist/v{}/SHASUMS256.txt",
            version.version()
        );
        let target = self.root.join(version.to_string());

        let download = download_artifact(&url, &filename, store, async || {
            let Some(checksums) = fetch_checksum(&checksum_url).await? else {
                return Ok(None);
            };
            digest_from_sha256sums(&checksums, &filename)
        })
        .await
        .context("Failed to download node")?;
        let extracted = archive::extract_archive(download.path())
            .await
            .context("Failed to extract node")?;
        if target.exists() {
            debug!(target = %target.display(), "Removing existing node");
            fs_err::tokio::remove_dir_all(&target).await?;
        }

        debug!(?extracted, target = %target.display(), "Moving node to target");
        crate::fs::rename_with_retry(&extracted, &target).await?;

        Ok(NodeResult::from_dir(&target, version.clone()))
    }

    /// Find a suitable system Node.js installation that matches the request.
    async fn find_system_node(&self, node_request: &NodeRequest) -> Result<Option<NodeResult>> {
        let node_paths = match find_system_executables(&*NODE_BINARY_NAME, &self.root) {
            Ok(paths) => paths,
            Err(e) => {
                debug!("No node executables found in PATH: {}", e);
                return Ok(None);
            }
        };

        // Check each node executable for a matching version, stop early if found
        for node_path in node_paths {
            if let Some(npm_path) = Self::find_npm_in_same_directory(&node_path)? {
                match NodeResult::from_executables(node_path, npm_path).await {
                    Ok(node_result) => {
                        // Check if this version matches the request
                        if node_request.matches(&node_result.version) {
                            trace!(
                                %node_result,
                                "Found a matching system node"
                            );
                            return Ok(Some(node_result));
                        }
                        trace!(
                            %node_result,
                            "System node does not match requested version"
                        );
                    }
                    Err(e) => {
                        warn!(?e, "Failed to get version for system node");
                    }
                }
            } else {
                trace!(
                    node = %node_path.display(),
                    "No npm found in same directory as node executable"
                );
            }
        }

        debug!(
            ?node_request,
            "No system node matches the requested version"
        );
        Ok(None)
    }

    /// Find npm executable in the same directory as the given node executable.
    fn find_npm_in_same_directory(node_path: &Path) -> Result<Option<PathBuf>> {
        let node_dir = node_path
            .parent()
            .context("Node executable has no parent directory")?;

        for name in ["npm", "npm.cmd", "npm.bat"] {
            let npm_path = node_dir.join(name);
            if npm_path.try_exists()? && is_executable(&npm_path) {
                trace!(
                    node = %node_path.display(),
                    npm = %npm_path.display(),
                    "Found npm in same directory as node"
                );
                return Ok(Some(npm_path));
            }
        }
        trace!(
            node = %node_path.display(),
            "npm not found in same directory as node"
        );
        Ok(None)
    }
}

pub(crate) fn bin_dir(prefix: &Path) -> PathBuf {
    if cfg!(windows) {
        prefix.to_path_buf()
    } else {
        prefix.join("bin")
    }
}

pub(crate) fn lib_dir(prefix: &Path) -> PathBuf {
    if cfg!(windows) {
        prefix.join("node_modules")
    } else {
        prefix.join("lib").join("node_modules")
    }
}