use anyhow::{Context, Result};
use check_updates_core::Version;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use crate::detector::LockfileType;
pub struct LockfileParser;
impl LockfileParser {
pub fn new() -> Self {
Self
}
pub fn parse(&self, path: &Path, lockfile_type: LockfileType) -> Result<HashMap<String, Version>> {
match lockfile_type {
LockfileType::Npm => self.parse_package_lock(path),
LockfileType::Pnpm => self.parse_pnpm_lock(path),
LockfileType::Yarn => self.parse_yarn_lock(path),
LockfileType::Bun => self.parse_bun_lock(path),
}
}
fn parse_package_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let parsed: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut versions = HashMap::new();
if let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) {
for (key, pkg_data) in packages {
if key.is_empty() {
continue;
}
let name = key
.strip_prefix("node_modules/")
.unwrap_or(key)
.to_string();
if name.contains("node_modules/") {
continue;
}
if let Some(version_str) = pkg_data.get("version").and_then(|v| v.as_str())
&& let Ok(version) = Version::from_str(version_str) {
versions.insert(name, version);
}
}
}
else if let Some(dependencies) = parsed.get("dependencies").and_then(|v| v.as_object()) {
self.parse_npm_v6_deps(dependencies, &mut versions);
}
Ok(versions)
}
fn parse_npm_v6_deps(
&self,
deps: &serde_json::Map<String, serde_json::Value>,
versions: &mut HashMap<String, Version>,
) {
for (name, data) in deps {
if let Some(version_str) = data.get("version").and_then(|v| v.as_str())
&& let Ok(version) = Version::from_str(version_str) {
versions.insert(name.clone(), version);
}
}
}
fn parse_pnpm_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let parsed: serde_yaml::Value = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut versions = HashMap::new();
if let Some(packages) = parsed.get("packages").and_then(|v| v.as_mapping()) {
for (key, _) in packages {
if let Some(key_str) = key.as_str()
&& let Some((name, version)) = Self::parse_pnpm_package_key(key_str) {
versions.insert(name, version);
}
}
}
if let Some(snapshots) = parsed.get("snapshots").and_then(|v| v.as_mapping()) {
for (key, _) in snapshots {
if let Some(key_str) = key.as_str()
&& let Some((name, version)) = Self::parse_pnpm_package_key(key_str) {
versions.entry(name).or_insert(version);
}
}
}
Ok(versions)
}
fn parse_pnpm_package_key(key: &str) -> Option<(String, Version)> {
let (name, version_str) = if let Some(rest) = key.strip_prefix('@') {
if let Some(at_pos) = rest.find('@') {
let name = &key[..at_pos + 1];
let version_part = &rest[at_pos + 1..];
let version_str = version_part.split('(').next().unwrap_or(version_part);
(name.to_string(), version_str)
} else {
return None;
}
} else {
let parts: Vec<&str> = key.splitn(2, '@').collect();
if parts.len() != 2 {
return None;
}
let version_str = parts[1].split('(').next().unwrap_or(parts[1]);
(parts[0].to_string(), version_str)
};
Version::from_str(version_str).ok().map(|v| (name, v))
}
fn parse_yarn_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let mut versions = HashMap::new();
let mut current_packages: Vec<String> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("version")
&& !trimmed.starts_with("resolved")
&& !trimmed.starts_with("integrity")
&& !trimmed.starts_with("dependencies")
&& !line.starts_with(' ')
&& !line.starts_with('\t')
{
current_packages = Self::parse_yarn_header(trimmed);
}
if trimmed.starts_with("version")
&& let Some(version) = Self::parse_yarn_version_line(trimmed) {
for pkg in ¤t_packages {
versions.entry(pkg.clone()).or_insert_with(|| version.clone());
}
}
}
Ok(versions)
}
fn parse_yarn_header(line: &str) -> Vec<String> {
let line = line.trim_end_matches(':');
let mut packages = Vec::new();
for part in line.split(", ") {
let part = part.trim().trim_matches('"');
if let Some(name) = Self::extract_package_name(part) {
packages.push(name);
}
}
packages
}
fn extract_package_name(spec: &str) -> Option<String> {
if let Some(rest) = spec.strip_prefix('@') {
if let Some(at_pos) = rest.find('@') {
return Some(spec[..at_pos + 1].to_string());
}
} else if let Some(at_pos) = spec.find('@') {
return Some(spec[..at_pos].to_string());
}
None
}
fn parse_yarn_version_line(line: &str) -> Option<Version> {
let line = line.trim_start_matches("version").trim();
let line = line.trim_start_matches(':').trim();
let version_str = line.trim_matches('"');
Version::from_str(version_str).ok()
}
fn parse_bun_lock(&self, _path: &Path) -> Result<HashMap<String, Version>> {
Ok(HashMap::new())
}
}
impl Default for LockfileParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_parse_package_lock_v7() -> Result<()> {
let mut file = NamedTempFile::with_suffix(".json")?;
writeln!(
file,
r#"{{
"name": "test",
"lockfileVersion": 3,
"packages": {{
"": {{}},
"node_modules/express": {{
"version": "4.18.2"
}},
"node_modules/lodash": {{
"version": "4.17.21"
}}
}}
}}"#
)?;
let parser = LockfileParser::new();
let versions = parser.parse(file.path(), LockfileType::Npm)?;
assert_eq!(versions.get("express").unwrap().to_string(), "4.18.2");
assert_eq!(versions.get("lodash").unwrap().to_string(), "4.17.21");
Ok(())
}
#[test]
fn test_parse_pnpm_package_key() {
let (name, version) = LockfileParser::parse_pnpm_package_key("express@4.18.2").unwrap();
assert_eq!(name, "express");
assert_eq!(version.to_string(), "4.18.2");
let (name, version) =
LockfileParser::parse_pnpm_package_key("@types/node@20.0.0").unwrap();
assert_eq!(name, "@types/node");
assert_eq!(version.to_string(), "20.0.0");
}
}