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)
}
}