rivox 1.0.0

Universal polyglot build coordination layer for Python, Rust, and Node monorepos
Documentation
use super::EcosystemAdapter;
use crate::graph::GraphNode;
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

pub struct NodeAdapter;

impl EcosystemAdapter for NodeAdapter {
    fn name(&self) -> &'static str {
        "node"
    }

    fn detect_tool(&self) -> Result<String> {
        let output = Command::new("pnpm").arg("--version").output().context(
            "Failed to execute `pnpm --version`. Ensure `pnpm` is installed and in PATH.",
        )?;

        if !output.status.success() {
            bail!(
                "`pnpm --version` failed with exit status: {}",
                output.status
            );
        }

        let version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
        Ok(version_str)
    }

    fn resolve(&self, workspace_path: &Path, frozen: bool) -> Result<PathBuf> {
        let lockfile_path = workspace_path.join("pnpm-lock.yaml");

        if frozen && lockfile_path.exists() {
            return Ok(lockfile_path);
        }

        let mut cmd = Command::new("pnpm");
        cmd.arg("install")
            .arg("--lockfile-only")
            .current_dir(workspace_path);

        if frozen {
            cmd.arg("--frozen-lockfile");
        }

        let output = cmd
            .output()
            .context("Failed to run `pnpm install --lockfile-only`")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            bail!(
                "`pnpm install --lockfile-only` failed in {}: {}",
                workspace_path.display(),
                stderr
            );
        }

        if !lockfile_path.exists() {
            bail!(
                "`pnpm install` completed but `pnpm-lock.yaml` missing at {}",
                lockfile_path.display()
            );
        }

        Ok(lockfile_path)
    }

    fn parse_lockfile(&self, lockfile_path: &Path) -> Result<Vec<GraphNode>> {
        let content = fs::read_to_string(lockfile_path).with_context(|| {
            format!(
                "Failed to read pnpm-lock.yaml at {}",
                lockfile_path.display()
            )
        })?;

        let parsed: serde_yaml::Value = serde_yaml::from_str(&content).with_context(|| {
            format!(
                "Failed to parse YAML in pnpm-lock.yaml at {}",
                lockfile_path.display()
            )
        })?;

        let mut nodes = Vec::new();

        if let Some(packages) = parsed.get("packages").and_then(|p| p.as_mapping()) {
            for (key, _val) in packages {
                if let Some(pkg_str) = key.as_str() {
                    let cleaned = pkg_str.trim_start_matches('/');
                    let parts: Vec<&str> = cleaned.split('@').collect();
                    let (name, version) = if parts.len() >= 2 {
                        (
                            parts[..parts.len() - 1].join("@"),
                            parts[parts.len() - 1].to_string(),
                        )
                    } else {
                        (cleaned.to_string(), "0.0.0".to_string())
                    };

                    let content_hash = format!(
                        "sha256:{}",
                        hex::encode(Sha256::digest(
                            format!("node:{}:{}", name, version).as_bytes()
                        ))
                    );

                    nodes.push(GraphNode {
                        ecosystem: "node".to_string(),
                        package_name: name,
                        resolved_version: version,
                        content_hash,
                        platform_markers: None,
                        lock_ref: lockfile_path.to_string_lossy().to_string(),
                    });
                }
            }
        }

        Ok(nodes)
    }
}