cuenv_workspaces/parsers/javascript/
npm.rs

1use 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/// Parser for npm `package-lock.json` files (lockfileVersion 3).
10#[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    // Workspace member detection based on npm lockfile v3 conventions:
139    // 1. Empty path "" is always the workspace root
140    // 2. Paths without "node_modules" are workspace members (e.g., "packages/app")
141    // 3. Paths containing "node_modules" are external dependencies, even if nested
142    //    within a workspace member path (e.g., "packages/app/node_modules/react")
143    //
144    // This heuristic aligns with npm's documented workspace layout where workspace
145    // members are listed by their relative path from the root, and external
146    // dependencies are always under a node_modules directory.
147    //
148    // Note: This approach is conservative and based on npm's consistent lockfile
149    // structure. A future enhancement could integrate explicit workspace discovery
150    // from package.json "workspaces" field for additional validation.
151    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        // Test that paths without node_modules are treated as workspace members
283        // This is the documented npm workspace convention
284        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        // All 4 entries should be workspace members
315        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        // Verify specific paths
325        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    #[allow(clippy::too_many_lines)] // Test requires comprehensive setup and assertions
334    fn distinguishes_workspace_from_nested_node_modules() {
335        let json = r#"{
336  "name": "workspace-root",
337  "version": "1.0.0",
338  "lockfileVersion": 3,
339  "packages": {
340	"": {
341	  "name": "workspace-root",
342	  "version": "1.0.0"
343	},
344	"packages/app": {
345	  "name": "app",
346	  "version": "0.1.0",
347	  "dependencies": {
348		"react": "^18.0.0"
349	  }
350	},
351	"packages/app/node_modules/react": {
352	  "version": "18.2.0",
353	  "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
354	  "integrity": "sha512-test"
355	},
356	"node_modules/left-pad": {
357	  "version": "1.3.0",
358	  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
359	  "integrity": "sha512-test"
360	}
361  }
362}"#;
363
364        let mut file = NamedTempFile::new().unwrap();
365        file.write_all(json.as_bytes()).unwrap();
366
367        let parser = NpmLockfileParser;
368        let entries = parser.parse(file.path()).unwrap();
369
370        // Should have 4 entries: workspace root, packages/app (workspace), and 2 registry deps
371        assert_eq!(entries.len(), 4);
372
373        // Check workspace root
374        let root = entries.iter().find(|e| e.name == "workspace-root").unwrap();
375        assert!(root.is_workspace_member);
376        assert!(matches!(root.source, DependencySource::Workspace(_)));
377
378        // Check workspace member packages/app
379        let app = entries.iter().find(|e| e.name == "app").unwrap();
380        assert!(app.is_workspace_member);
381        assert!(matches!(app.source, DependencySource::Workspace(_)));
382
383        // Check react from packages/app/node_modules - should be registry dep
384        let react_entries: Vec<_> = entries.iter().filter(|e| e.name == "react").collect();
385        assert_eq!(react_entries.len(), 1);
386        let react = react_entries[0];
387        assert!(
388            !react.is_workspace_member,
389            "React in nested node_modules should not be a workspace member"
390        );
391        assert!(
392            matches!(react.source, DependencySource::Registry(_)),
393            "React should be a registry dependency"
394        );
395
396        // Check left-pad from node_modules - should be registry dep
397        let left_pad = entries.iter().find(|e| e.name == "left-pad").unwrap();
398        assert!(!left_pad.is_workspace_member);
399        assert!(matches!(left_pad.source, DependencySource::Registry(_)));
400    }
401}