1use anyhow::{Context, Result};
2use check_updates_core::Version;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6use std::str::FromStr;
7
8use crate::detector::LockfileType;
9
10pub struct LockfileParser;
11
12impl LockfileParser {
13 pub fn new() -> Self {
14 Self
15 }
16
17 pub fn parse(&self, path: &Path, lockfile_type: LockfileType) -> Result<HashMap<String, Version>> {
19 match lockfile_type {
20 LockfileType::Npm => self.parse_package_lock(path),
21 LockfileType::Pnpm => self.parse_pnpm_lock(path),
22 LockfileType::Yarn => self.parse_yarn_lock(path),
23 LockfileType::Bun => self.parse_bun_lock(path),
24 }
25 }
26
27 fn parse_package_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
29 let content = fs::read_to_string(path)
30 .with_context(|| format!("Failed to read {}", path.display()))?;
31
32 let parsed: serde_json::Value = serde_json::from_str(&content)
33 .with_context(|| format!("Failed to parse {}", path.display()))?;
34
35 let mut versions = HashMap::new();
36
37 if let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) {
39 for (key, pkg_data) in packages {
40 if key.is_empty() {
42 continue;
43 }
44
45 let name = key
47 .strip_prefix("node_modules/")
48 .unwrap_or(key)
49 .to_string();
50
51 if name.contains("node_modules/") {
53 continue;
54 }
55
56 if let Some(version_str) = pkg_data.get("version").and_then(|v| v.as_str())
57 && let Ok(version) = Version::from_str(version_str) {
58 versions.insert(name, version);
59 }
60 }
61 }
62 else if let Some(dependencies) = parsed.get("dependencies").and_then(|v| v.as_object()) {
64 self.parse_npm_v6_deps(dependencies, &mut versions);
65 }
66
67 Ok(versions)
68 }
69
70 fn parse_npm_v6_deps(
71 &self,
72 deps: &serde_json::Map<String, serde_json::Value>,
73 versions: &mut HashMap<String, Version>,
74 ) {
75 for (name, data) in deps {
76 if let Some(version_str) = data.get("version").and_then(|v| v.as_str())
77 && let Ok(version) = Version::from_str(version_str) {
78 versions.insert(name.clone(), version);
79 }
80 }
81 }
82
83 fn parse_pnpm_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
85 let content = fs::read_to_string(path)
86 .with_context(|| format!("Failed to read {}", path.display()))?;
87
88 let parsed: serde_yaml::Value = serde_yaml::from_str(&content)
89 .with_context(|| format!("Failed to parse {}", path.display()))?;
90
91 let mut versions = HashMap::new();
92
93 if let Some(packages) = parsed.get("packages").and_then(|v| v.as_mapping()) {
96 for (key, _) in packages {
97 if let Some(key_str) = key.as_str()
98 && let Some((name, version)) = Self::parse_pnpm_package_key(key_str) {
99 versions.insert(name, version);
100 }
101 }
102 }
103
104 if let Some(snapshots) = parsed.get("snapshots").and_then(|v| v.as_mapping()) {
106 for (key, _) in snapshots {
107 if let Some(key_str) = key.as_str()
108 && let Some((name, version)) = Self::parse_pnpm_package_key(key_str) {
109 versions.entry(name).or_insert(version);
110 }
111 }
112 }
113
114 Ok(versions)
115 }
116
117 fn parse_pnpm_package_key(key: &str) -> Option<(String, Version)> {
119 let (name, version_str) = if let Some(rest) = key.strip_prefix('@') {
121 if let Some(at_pos) = rest.find('@') {
123 let name = &key[..at_pos + 1];
124 let version_part = &rest[at_pos + 1..];
125 let version_str = version_part.split('(').next().unwrap_or(version_part);
127 (name.to_string(), version_str)
128 } else {
129 return None;
130 }
131 } else {
132 let parts: Vec<&str> = key.splitn(2, '@').collect();
134 if parts.len() != 2 {
135 return None;
136 }
137 let version_str = parts[1].split('(').next().unwrap_or(parts[1]);
138 (parts[0].to_string(), version_str)
139 };
140
141 Version::from_str(version_str).ok().map(|v| (name, v))
142 }
143
144 fn parse_yarn_lock(&self, path: &Path) -> Result<HashMap<String, Version>> {
146 let content = fs::read_to_string(path)
147 .with_context(|| format!("Failed to read {}", path.display()))?;
148
149 let mut versions = HashMap::new();
150
151 let mut current_packages: Vec<String> = Vec::new();
156
157 for line in content.lines() {
158 let trimmed = line.trim();
159
160 if !trimmed.is_empty()
162 && !trimmed.starts_with('#')
163 && !trimmed.starts_with("version")
164 && !trimmed.starts_with("resolved")
165 && !trimmed.starts_with("integrity")
166 && !trimmed.starts_with("dependencies")
167 && !line.starts_with(' ')
168 && !line.starts_with('\t')
169 {
170 current_packages = Self::parse_yarn_header(trimmed);
171 }
172
173 if trimmed.starts_with("version")
175 && let Some(version) = Self::parse_yarn_version_line(trimmed) {
176 for pkg in ¤t_packages {
177 versions.entry(pkg.clone()).or_insert_with(|| version.clone());
178 }
179 }
180 }
181
182 Ok(versions)
183 }
184
185 fn parse_yarn_header(line: &str) -> Vec<String> {
186 let line = line.trim_end_matches(':');
189 let mut packages = Vec::new();
190
191 for part in line.split(", ") {
192 let part = part.trim().trim_matches('"');
193 if let Some(name) = Self::extract_package_name(part) {
195 packages.push(name);
196 }
197 }
198
199 packages
200 }
201
202 fn extract_package_name(spec: &str) -> Option<String> {
203 if let Some(rest) = spec.strip_prefix('@') {
205 if let Some(at_pos) = rest.find('@') {
206 return Some(spec[..at_pos + 1].to_string());
207 }
208 } else if let Some(at_pos) = spec.find('@') {
209 return Some(spec[..at_pos].to_string());
210 }
211 None
212 }
213
214 fn parse_yarn_version_line(line: &str) -> Option<Version> {
215 let line = line.trim_start_matches("version").trim();
217 let line = line.trim_start_matches(':').trim();
218 let version_str = line.trim_matches('"');
219 Version::from_str(version_str).ok()
220 }
221
222 fn parse_bun_lock(&self, _path: &Path) -> Result<HashMap<String, Version>> {
224 Ok(HashMap::new())
227 }
228}
229
230impl Default for LockfileParser {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::io::Write;
240 use tempfile::NamedTempFile;
241
242 #[test]
243 fn test_parse_package_lock_v7() -> Result<()> {
244 let mut file = NamedTempFile::with_suffix(".json")?;
245 writeln!(
246 file,
247 r#"{{
248 "name": "test",
249 "lockfileVersion": 3,
250 "packages": {{
251 "": {{}},
252 "node_modules/express": {{
253 "version": "4.18.2"
254 }},
255 "node_modules/lodash": {{
256 "version": "4.17.21"
257 }}
258 }}
259}}"#
260 )?;
261
262 let parser = LockfileParser::new();
263 let versions = parser.parse(file.path(), LockfileType::Npm)?;
264
265 assert_eq!(versions.get("express").unwrap().to_string(), "4.18.2");
266 assert_eq!(versions.get("lodash").unwrap().to_string(), "4.17.21");
267
268 Ok(())
269 }
270
271 #[test]
272 fn test_parse_pnpm_package_key() {
273 let (name, version) = LockfileParser::parse_pnpm_package_key("express@4.18.2").unwrap();
274 assert_eq!(name, "express");
275 assert_eq!(version.to_string(), "4.18.2");
276
277 let (name, version) =
278 LockfileParser::parse_pnpm_package_key("@types/node@20.0.0").unwrap();
279 assert_eq!(name, "@types/node");
280 assert_eq!(version.to_string(), "20.0.0");
281 }
282}