cuenv_workspaces/parsers/javascript/
npm.rs1use crate::core::traits::LockfileParser;
2use crate::core::types::{DependencyRef, DependencySource, LockfileEntry};
3use crate::error::{Error, Result};
4use serde::Deserialize;
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Default, Clone, Copy)]
11pub struct NpmLockfileParser;
12
13impl LockfileParser for NpmLockfileParser {
14 fn parse(&self, lockfile_path: &Path) -> Result<Vec<LockfileEntry>> {
15 let contents = fs::read_to_string(lockfile_path).map_err(|source| Error::Io {
16 source,
17 path: Some(lockfile_path.to_path_buf()),
18 operation: "reading package-lock.json".to_string(),
19 })?;
20
21 let lockfile: PackageLockV3 =
22 serde_json::from_str(&contents).map_err(|source| Error::LockfileParseFailed {
23 path: lockfile_path.to_path_buf(),
24 message: source.to_string(),
25 })?;
26
27 if lockfile.lockfile_version != 3 {
28 return Err(Error::LockfileParseFailed {
29 path: lockfile_path.to_path_buf(),
30 message: format!(
31 "Unsupported lockfileVersion {} – only v3 is supported",
32 lockfile.lockfile_version
33 ),
34 });
35 }
36
37 let workspace_name = lockfile.name.unwrap_or_else(|| "workspace".to_string());
38 let workspace_version = lockfile.version.unwrap_or_else(|| "0.0.0".to_string());
39
40 let mut entries = Vec::new();
41 for (pkg_path, pkg_entry) in lockfile.packages.unwrap_or_default() {
42 if let Some(entry) = entry_from_package(
43 lockfile_path,
44 &pkg_path,
45 &pkg_entry,
46 &workspace_name,
47 &workspace_version,
48 )? {
49 entries.push(entry);
50 }
51 }
52
53 Ok(entries)
54 }
55
56 fn supports_lockfile(&self, path: &Path) -> bool {
57 matches!(
58 path.file_name().and_then(|n| n.to_str()),
59 Some("package-lock.json")
60 )
61 }
62
63 fn lockfile_name(&self) -> &'static str {
64 "package-lock.json"
65 }
66}
67
68#[derive(Debug, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct PackageLockV3 {
71 #[serde(default)]
72 lockfile_version: u32,
73 #[serde(default)]
74 name: Option<String>,
75 #[serde(default)]
76 version: Option<String>,
77 #[serde(default)]
78 packages: Option<BTreeMap<String, PackageEntry>>,
79}
80
81#[derive(Debug, Deserialize, Default)]
82#[serde(rename_all = "camelCase")]
83struct PackageEntry {
84 #[serde(default)]
85 name: Option<String>,
86 #[serde(default)]
87 version: Option<String>,
88 #[serde(default)]
89 resolved: Option<String>,
90 #[serde(default)]
91 integrity: Option<String>,
92 #[serde(default)]
93 dependencies: BTreeMap<String, String>,
94 #[serde(default, rename = "devDependencies")]
95 dev_dependencies: BTreeMap<String, String>,
96 #[serde(default, rename = "optionalDependencies")]
97 optional_dependencies: BTreeMap<String, String>,
98}
99
100fn entry_from_package(
101 lockfile_path: &Path,
102 pkg_path: &str,
103 pkg_entry: &PackageEntry,
104 workspace_name: &str,
105 workspace_version: &str,
106) -> Result<Option<LockfileEntry>> {
107 let version = if pkg_path.is_empty() {
108 pkg_entry
109 .version
110 .clone()
111 .unwrap_or_else(|| workspace_version.to_string())
112 } else {
113 pkg_entry
114 .version
115 .clone()
116 .ok_or_else(|| Error::LockfileParseFailed {
117 path: lockfile_path.to_path_buf(),
118 message: format!("Missing version for package entry '{pkg_path}': {pkg_entry:?}",),
119 })?
120 };
121
122 Ok(Some(build_entry(
123 pkg_path,
124 pkg_entry,
125 workspace_name,
126 version,
127 )))
128}
129
130fn build_entry(
131 pkg_path: &str,
132 pkg_entry: &PackageEntry,
133 workspace_name: &str,
134 version: String,
135) -> LockfileEntry {
136 let name = infer_package_name(pkg_path, pkg_entry, workspace_name);
137
138 let is_workspace_member = pkg_path.is_empty()
152 || (!pkg_path.starts_with("node_modules") && !pkg_path.contains("/node_modules/"));
153
154 let source = if is_workspace_member {
155 DependencySource::Workspace(workspace_source_path(pkg_path))
156 } else {
157 DependencySource::Registry(
158 pkg_entry
159 .resolved
160 .clone()
161 .unwrap_or_else(|| format!("npm:{name}")),
162 )
163 };
164
165 let checksum = pkg_entry.integrity.clone();
166 let mut dependencies = Vec::new();
167 dependencies.extend(map_dependencies(&pkg_entry.dependencies));
168 dependencies.extend(map_dependencies(&pkg_entry.dev_dependencies));
169 dependencies.extend(map_dependencies(&pkg_entry.optional_dependencies));
170
171 LockfileEntry {
172 name,
173 version,
174 source,
175 checksum,
176 dependencies,
177 is_workspace_member,
178 }
179}
180
181fn workspace_source_path(pkg_path: &str) -> PathBuf {
182 if pkg_path.is_empty() {
183 PathBuf::from(".")
184 } else {
185 PathBuf::from(pkg_path)
186 }
187}
188
189fn infer_package_name(pkg_path: &str, pkg_entry: &PackageEntry, workspace_name: &str) -> String {
190 if let Some(name) = &pkg_entry.name {
191 return name.clone();
192 }
193
194 if pkg_path.is_empty() {
195 return workspace_name.to_string();
196 }
197
198 let trimmed = pkg_path.trim_start_matches("node_modules/");
199 trimmed.rsplit('/').next().unwrap_or(trimmed).to_string()
200}
201
202fn map_dependencies(deps: &BTreeMap<String, String>) -> Vec<DependencyRef> {
203 deps.iter()
204 .map(|(name, version)| DependencyRef {
205 name: name.clone(),
206 version_req: version.clone(),
207 })
208 .collect()
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::io::Write;
215 use tempfile::NamedTempFile;
216
217 #[test]
218 fn parses_basic_package_lock() {
219 let json = r#"{
220 "name": "acme-app",
221 "version": "1.0.0",
222 "lockfileVersion": 3,
223 "packages": {
224 "": {
225 "name": "acme-app",
226 "version": "1.0.0",
227 "dependencies": {
228 "left-pad": "^1.3.0"
229 }
230 },
231 "node_modules/left-pad": {
232 "version": "1.3.0",
233 "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
234 "integrity": "sha512-test",
235 "dependencies": {
236 "repeat-string": "^1.6.1"
237 }
238 }
239 }
240}"#;
241
242 let mut file = NamedTempFile::new().unwrap();
243 file.write_all(json.as_bytes()).unwrap();
244
245 let parser = NpmLockfileParser;
246 let entries = parser.parse(file.path()).unwrap();
247 assert_eq!(entries.len(), 2);
248
249 let workspace = entries.iter().find(|e| e.is_workspace_member).unwrap();
250 assert_eq!(workspace.name, "acme-app");
251 assert_eq!(workspace.version, "1.0.0");
252 assert_eq!(workspace.dependencies.len(), 1);
253
254 let dep = entries
255 .iter()
256 .find(|e| e.name == "left-pad")
257 .expect("left-pad entry");
258 assert_eq!(dep.version, "1.3.0");
259 assert_eq!(dep.checksum.as_deref(), Some("sha512-test"));
260 assert_eq!(dep.dependencies.len(), 1);
261 assert!(!dep.is_workspace_member);
262 }
263
264 #[test]
265 fn rejects_wrong_version() {
266 let json = r#"{"lockfileVersion": 2, "packages": {}}"#;
267 let mut file = NamedTempFile::new().unwrap();
268 file.write_all(json.as_bytes()).unwrap();
269
270 let parser = NpmLockfileParser;
271 let err = parser.parse(file.path()).unwrap_err();
272 match err {
273 Error::LockfileParseFailed { message, .. } => {
274 assert!(message.contains("lockfileVersion"));
275 }
276 other => panic!("unexpected error: {other:?}"),
277 }
278 }
279
280 #[test]
281 fn treats_non_node_modules_paths_as_workspace_members() {
282 let json = r#"{
285 "name": "monorepo",
286 "version": "1.0.0",
287 "lockfileVersion": 3,
288 "packages": {
289 "": {
290 "name": "monorepo",
291 "version": "1.0.0"
292 },
293 "apps/web": {
294 "name": "web",
295 "version": "0.1.0"
296 },
297 "packages/shared": {
298 "name": "shared",
299 "version": "0.2.0"
300 },
301 "libs/utils": {
302 "name": "utils",
303 "version": "0.3.0"
304 }
305 }
306}"#;
307
308 let mut file = NamedTempFile::new().unwrap();
309 file.write_all(json.as_bytes()).unwrap();
310
311 let parser = NpmLockfileParser;
312 let entries = parser.parse(file.path()).unwrap();
313
314 assert_eq!(entries.len(), 4);
316 for entry in &entries {
317 assert!(
318 entry.is_workspace_member,
319 "Entry '{}' at path should be a workspace member",
320 entry.name
321 );
322 }
323
324 let web = entries.iter().find(|e| e.name == "web").unwrap();
326 assert!(matches!(web.source, DependencySource::Workspace(_)));
327
328 let shared = entries.iter().find(|e| e.name == "shared").unwrap();
329 assert!(matches!(shared.source, DependencySource::Workspace(_)));
330 }
331
332 #[test]
333 fn distinguishes_workspace_from_nested_node_modules() {
334 let json = r#"{
335 "name": "workspace-root",
336 "version": "1.0.0",
337 "lockfileVersion": 3,
338 "packages": {
339 "": {
340 "name": "workspace-root",
341 "version": "1.0.0"
342 },
343 "packages/app": {
344 "name": "app",
345 "version": "0.1.0",
346 "dependencies": {
347 "react": "^18.0.0"
348 }
349 },
350 "packages/app/node_modules/react": {
351 "version": "18.2.0",
352 "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
353 "integrity": "sha512-test"
354 },
355 "node_modules/left-pad": {
356 "version": "1.3.0",
357 "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
358 "integrity": "sha512-test"
359 }
360 }
361}"#;
362
363 let mut file = NamedTempFile::new().unwrap();
364 file.write_all(json.as_bytes()).unwrap();
365
366 let parser = NpmLockfileParser;
367 let entries = parser.parse(file.path()).unwrap();
368
369 assert_eq!(entries.len(), 4);
371
372 let root = entries.iter().find(|e| e.name == "workspace-root").unwrap();
374 assert!(root.is_workspace_member);
375 assert!(matches!(root.source, DependencySource::Workspace(_)));
376
377 let app = entries.iter().find(|e| e.name == "app").unwrap();
379 assert!(app.is_workspace_member);
380 assert!(matches!(app.source, DependencySource::Workspace(_)));
381
382 let react_entries: Vec<_> = entries.iter().filter(|e| e.name == "react").collect();
384 assert_eq!(react_entries.len(), 1);
385 let react = react_entries[0];
386 assert!(
387 !react.is_workspace_member,
388 "React in nested node_modules should not be a workspace member"
389 );
390 assert!(
391 matches!(react.source, DependencySource::Registry(_)),
392 "React should be a registry dependency"
393 );
394
395 let left_pad = entries.iter().find(|e| e.name == "left-pad").unwrap();
397 assert!(!left_pad.is_workspace_member);
398 assert!(matches!(left_pad.source, DependencySource::Registry(_)));
399 }
400}