cuenv_workspaces/parsers/javascript/
pnpm.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 pnpm `pnpm-lock.yaml` files.
10#[derive(Debug, Default, Clone, Copy)]
11pub struct PnpmLockfileParser;
12
13impl LockfileParser for PnpmLockfileParser {
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 pnpm-lock.yaml".to_string(),
19        })?;
20
21        let lockfile: PnpmLockfile =
22            serde_yaml::from_str(&contents).map_err(|source| Error::LockfileParseFailed {
23                path: lockfile_path.to_path_buf(),
24                message: source.to_string(),
25            })?;
26
27        // Validate lockfileVersion format (accept any valid numeric version)
28        // We accept all numeric lockfile versions and only reject clearly invalid formats.
29        // This allows the parser to work with future pnpm versions without requiring updates.
30        if let Some(ref version_str) = lockfile.lockfile_version {
31            // Parse version string (e.g., "5.4", "6.0", "9.0")
32            let major_version = version_str
33                .split('.')
34                .next()
35                .and_then(|v| v.trim_matches('\'').parse::<u32>().ok());
36
37            if major_version.is_none() {
38                return Err(Error::LockfileParseFailed {
39                    path: lockfile_path.to_path_buf(),
40                    message: format!(
41                        "Invalid pnpm lockfileVersion format: '{version_str}'. Expected a numeric version like '6.0'.",
42                    ),
43                });
44            }
45
46            // Log a warning for versions newer than what we've tested (9.0)
47            if let Some(major) = major_version
48                && major > 9
49            {
50                tracing::warn!(
51                    "Encountered pnpm lockfile version '{version_str}' which is newer than the highest tested version (9.0). Parsing may fail or be incomplete.",
52                );
53            }
54            // Accept all valid numeric versions (no version-specific rejection)
55        }
56        // If lockfileVersion is missing, proceed (compatible with older pnpm versions)
57
58        let mut entries = Vec::new();
59
60        // Parse workspace importers (workspace members)
61        for (importer_path, importer) in lockfile.importers {
62            let entry = entry_from_importer(&importer_path, &importer);
63            entries.push(entry);
64        }
65
66        // Parse external packages
67        for (package_key, package_info) in lockfile.packages {
68            let entry = entry_from_package(lockfile_path, &package_key, &package_info)?;
69            entries.push(entry);
70        }
71
72        Ok(entries)
73    }
74
75    fn supports_lockfile(&self, path: &Path) -> bool {
76        matches!(
77            path.file_name().and_then(|n| n.to_str()),
78            Some("pnpm-lock.yaml")
79        )
80    }
81
82    fn lockfile_name(&self) -> &'static str {
83        "pnpm-lock.yaml"
84    }
85}
86
87#[derive(Debug, Deserialize, Default)]
88#[serde(rename_all = "camelCase")]
89struct PnpmLockfile {
90    #[serde(default)]
91    lockfile_version: Option<String>,
92    #[serde(default)]
93    importers: BTreeMap<String, PnpmImporter>,
94    #[serde(default)]
95    packages: BTreeMap<String, PnpmPackage>,
96}
97
98#[derive(Debug, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100struct PnpmImporter {
101    #[serde(default)]
102    dependencies: BTreeMap<String, String>,
103    #[serde(default)]
104    dev_dependencies: BTreeMap<String, String>,
105    #[serde(default)]
106    optional_dependencies: BTreeMap<String, String>,
107    #[serde(default)]
108    #[allow(dead_code)]
109    specifiers: BTreeMap<String, String>,
110}
111
112#[derive(Debug, Deserialize, Default)]
113#[serde(rename_all = "camelCase")]
114struct PnpmPackage {
115    #[serde(default)]
116    resolution: Option<PnpmResolution>,
117    #[serde(default)]
118    dependencies: BTreeMap<String, String>,
119    #[serde(default)]
120    dev_dependencies: BTreeMap<String, String>,
121    #[serde(default)]
122    optional_dependencies: BTreeMap<String, String>,
123    #[serde(default)]
124    peer_dependencies: BTreeMap<String, String>,
125    /// Integrity checksum (e.g., "sha512-...")
126    #[serde(default)]
127    integrity: Option<String>,
128    #[serde(default)]
129    #[allow(dead_code)]
130    dev: bool,
131    #[serde(default)]
132    #[allow(dead_code)]
133    optional: bool,
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(untagged)]
138enum PnpmResolution {
139    Registry { integrity: String, tarball: String },
140    Git { repo: String, commit: String },
141    Object(BTreeMap<String, serde_yaml::Value>),
142}
143
144fn entry_from_importer(importer_path: &str, importer: &PnpmImporter) -> LockfileEntry {
145    let name = if importer_path == "." {
146        "workspace-root".to_string()
147    } else {
148        importer_path
149            .trim_start_matches("./")
150            .rsplit('/')
151            .next()
152            .unwrap_or(importer_path)
153            .to_string()
154    };
155
156    let mut dependencies = Vec::new();
157    push_dependencies(&mut dependencies, &importer.dependencies);
158    push_dependencies(&mut dependencies, &importer.dev_dependencies);
159    push_dependencies(&mut dependencies, &importer.optional_dependencies);
160
161    let path = if importer_path == "." {
162        PathBuf::from(".")
163    } else {
164        PathBuf::from(importer_path.trim_start_matches("./"))
165    };
166
167    LockfileEntry {
168        name,
169        version: "0.0.0".to_string(),
170        source: DependencySource::Workspace(path),
171        checksum: None,
172        dependencies,
173        is_workspace_member: true,
174    }
175}
176
177fn entry_from_package(
178    lockfile_path: &Path,
179    package_key: &str,
180    package_info: &PnpmPackage,
181) -> Result<LockfileEntry> {
182    // pnpm package keys look like: "/@babel/core/7.22.5" or "/left-pad/1.3.0"
183    let (name, version) = parse_package_key(lockfile_path, package_key)?;
184
185    let source = determine_source(&name, package_info);
186
187    // Extract checksum from either the top-level integrity field or from the resolution
188    let checksum = package_info.integrity.clone().or_else(|| {
189        package_info.resolution.as_ref().and_then(|res| match res {
190            PnpmResolution::Registry { integrity, .. } => Some(integrity.clone()),
191            PnpmResolution::Object(map) => map
192                .get("integrity")
193                .and_then(|v| v.as_str())
194                .map(ToString::to_string),
195            PnpmResolution::Git { .. } => None,
196        })
197    });
198
199    let mut dependencies = Vec::new();
200    push_dependencies(&mut dependencies, &package_info.dependencies);
201    push_dependencies(&mut dependencies, &package_info.dev_dependencies);
202    push_dependencies(&mut dependencies, &package_info.optional_dependencies);
203    push_dependencies(&mut dependencies, &package_info.peer_dependencies);
204
205    Ok(LockfileEntry {
206        name,
207        version,
208        source,
209        checksum,
210        dependencies,
211        is_workspace_member: false,
212    })
213}
214
215fn parse_package_key(lockfile_path: &Path, package_key: &str) -> Result<(String, String)> {
216    // Remove leading "/" if present
217    let key = package_key.trim_start_matches('/');
218
219    // Handle scoped packages like "@babel/core/7.22.5" or "/@babel/core@7.22.5"
220    if key.starts_with('@') {
221        // Find the second slash which separates the package name from the version
222        // For example: "@babel/core/7.22.5" -> ["@babel", "core", "7.22.5"]
223        let parts: Vec<&str> = key.splitn(3, '/').collect();
224        if parts.len() >= 3 {
225            // parts[0] = "@babel", parts[1] = "core", parts[2] = "7.22.5"
226            let name = format!("{}/{}", parts[0], parts[1]);
227            let version_part = parts[2];
228
229            // Remove pnpm peer dependency suffixes like "(@types/node@20.0.0)"
230            let version = version_part
231                .split('(')
232                .next()
233                .unwrap_or(version_part)
234                .trim_end_matches(')');
235
236            return Ok((name, version.to_string()));
237        } else if parts.len() == 2 {
238            // Handle "@babel/core@7.22.5" format (with @)
239            let name = format!(
240                "{}/{}",
241                parts[0],
242                parts[1].split('@').next().unwrap_or(parts[1])
243            );
244            let version = parts[1]
245                .split('@')
246                .nth(1)
247                .ok_or_else(|| Error::LockfileParseFailed {
248                    path: lockfile_path.to_path_buf(),
249                    message: format!("Invalid scoped package key format: {package_key}"),
250                })?;
251
252            let version = version
253                .split('(')
254                .next()
255                .unwrap_or(version)
256                .trim_end_matches(')');
257
258            return Ok((name, version.to_string()));
259        }
260    }
261
262    // Handle regular packages like "left-pad@1.3.0" or "left-pad/1.3.0"
263    // pnpm uses @ as separator between name and version
264    if let Some(at_idx) = key.rfind('@') {
265        let name = &key[..at_idx];
266        let version = &key[at_idx + 1..];
267
268        // Remove pnpm peer dependency suffixes
269        let version = version
270            .split('(')
271            .next()
272            .unwrap_or(version)
273            .trim_end_matches(')');
274
275        Ok((name.to_string(), version.to_string()))
276    } else if let Some(last_slash) = key.rfind('/') {
277        // Fallback to slash separator
278        let name = &key[..last_slash];
279        let version = &key[last_slash + 1..];
280
281        // Remove pnpm peer dependency suffixes
282        let version = version
283            .split('(')
284            .next()
285            .unwrap_or(version)
286            .trim_end_matches(')');
287
288        Ok((name.to_string(), version.to_string()))
289    } else {
290        Err(Error::LockfileParseFailed {
291            path: lockfile_path.to_path_buf(),
292            message: format!("Invalid pnpm package key format: {package_key}"),
293        })
294    }
295}
296
297fn determine_source(name: &str, package_info: &PnpmPackage) -> DependencySource {
298    if let Some(resolution) = &package_info.resolution {
299        match resolution {
300            PnpmResolution::Registry { tarball, .. } => DependencySource::Registry(tarball.clone()),
301            PnpmResolution::Git { repo, commit } => {
302                DependencySource::Git(format!("{repo}#{commit}"))
303            }
304            PnpmResolution::Object(map) => {
305                // Check for various resolution types
306                if let Some(tarball) = map.get("tarball").and_then(|v| v.as_str()) {
307                    DependencySource::Registry(tarball.to_string())
308                } else if let Some(repo) = map.get("repo").and_then(|v| v.as_str()) {
309                    let commit = map.get("commit").and_then(|v| v.as_str()).unwrap_or("HEAD");
310                    DependencySource::Git(format!("{repo}#{commit}"))
311                } else if let Some(dir) = map.get("directory").and_then(|v| v.as_str()) {
312                    DependencySource::Path(PathBuf::from(dir))
313                } else {
314                    // Default to registry with package name
315                    DependencySource::Registry(format!("npm:{name}"))
316                }
317            }
318        }
319    } else {
320        // No resolution info, assume npm registry
321        DependencySource::Registry(format!("npm:{name}"))
322    }
323}
324
325fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
326    for (name, version_req) in deps {
327        target.push(DependencyRef {
328            name: name.clone(),
329            version_req: version_req.clone(),
330        });
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::needless_raw_string_hashes, clippy::uninlined_format_args)]
336mod tests {
337    use super::*;
338    use std::io::Write;
339    use tempfile::NamedTempFile;
340
341    #[test]
342    fn parses_basic_pnpm_lock() {
343        let yaml = r#"
344lockfileVersion: '6.0'
345
346importers:
347  .:
348    dependencies:
349      left-pad: 1.3.0
350
351packages:
352  /left-pad@1.3.0:
353    resolution:
354      integrity: sha512-test123
355      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
356    dev: false
357"#;
358
359        let mut file = NamedTempFile::new().unwrap();
360        file.write_all(yaml.as_bytes()).unwrap();
361
362        let parser = PnpmLockfileParser;
363        let entries = parser.parse(file.path()).unwrap();
364
365        assert!(entries.len() >= 2);
366
367        let workspace = entries
368            .iter()
369            .find(|e| e.is_workspace_member)
370            .expect("workspace root");
371        assert_eq!(workspace.dependencies.len(), 1);
372
373        let left_pad = entries
374            .iter()
375            .find(|e| e.name == "left-pad")
376            .expect("left-pad");
377        assert_eq!(left_pad.version, "1.3.0");
378        assert!(!left_pad.is_workspace_member);
379    }
380
381    #[test]
382    fn parses_scoped_packages() {
383        let yaml = r#"
384lockfileVersion: '6.0'
385
386importers:
387  .:
388    dependencies: {}
389
390packages:
391  /@babel/core@7.22.5:
392    resolution:
393      integrity: sha512-xyz
394      tarball: https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz
395    dev: false
396"#;
397
398        let mut file = NamedTempFile::new().unwrap();
399        file.write_all(yaml.as_bytes()).unwrap();
400
401        let parser = PnpmLockfileParser;
402        let entries = parser.parse(file.path()).unwrap();
403
404        let babel = entries
405            .iter()
406            .find(|e| e.name == "@babel/core")
407            .expect("@babel/core");
408        assert_eq!(babel.version, "7.22.5");
409    }
410
411    #[test]
412    fn supports_expected_filename() {
413        let parser = PnpmLockfileParser;
414        assert!(parser.supports_lockfile(Path::new("/tmp/pnpm-lock.yaml")));
415        assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
416    }
417
418    #[test]
419    fn accepts_various_lockfile_versions() {
420        // Test that we accept various lockfile versions (5.4, 6.0, 9.0, etc.)
421        for version in ["5.4", "6.0", "7.0", "9.0", "10.0"] {
422            let yaml = format!(
423                r#"
424lockfileVersion: '{}'
425
426importers:
427  .:
428    dependencies:
429      left-pad: 1.3.0
430
431packages:
432  /left-pad@1.3.0:
433    resolution:
434      integrity: sha512-test
435      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
436    dev: false
437"#,
438                version
439            );
440
441            let mut file = NamedTempFile::new().unwrap();
442            file.write_all(yaml.as_bytes()).unwrap();
443
444            let parser = PnpmLockfileParser;
445            let result = parser.parse(file.path());
446            assert!(
447                result.is_ok(),
448                "Version {} should be accepted, got error: {:?}",
449                version,
450                result.err()
451            );
452        }
453    }
454
455    #[test]
456    fn rejects_invalid_lockfile_version_format() {
457        let yaml = r#"
458lockfileVersion: 'invalid'
459
460importers:
461  .:
462    dependencies: {}
463
464packages: {}
465"#;
466
467        let mut file = NamedTempFile::new().unwrap();
468        file.write_all(yaml.as_bytes()).unwrap();
469
470        let parser = PnpmLockfileParser;
471        let err = parser.parse(file.path()).unwrap_err();
472
473        match err {
474            Error::LockfileParseFailed { message, .. } => {
475                assert!(message.contains("Invalid pnpm lockfileVersion format"));
476                assert!(message.contains("invalid"));
477            }
478            other => panic!("Expected LockfileParseFailed, got: {:?}", other),
479        }
480    }
481
482    #[test]
483    fn accepts_supported_versions() {
484        for version in ["6.0", "9.0"] {
485            let yaml = format!(
486                r#"
487lockfileVersion: '{}'
488
489importers:
490  .:
491    dependencies:
492      left-pad: 1.3.0
493
494packages:
495  /left-pad@1.3.0:
496    resolution:
497      integrity: sha512-test
498      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
499    dev: false
500"#,
501                version
502            );
503
504            let mut file = NamedTempFile::new().unwrap();
505            file.write_all(yaml.as_bytes()).unwrap();
506
507            let parser = PnpmLockfileParser;
508            let result = parser.parse(file.path());
509            assert!(
510                result.is_ok(),
511                "Version {} should be supported, got error: {:?}",
512                version,
513                result.err()
514            );
515        }
516    }
517
518    #[test]
519    fn accepts_missing_lockfile_version() {
520        // Older pnpm versions may not have lockfileVersion
521        let yaml = r#"
522importers:
523  .:
524    dependencies:
525      left-pad: 1.3.0
526
527packages:
528  /left-pad@1.3.0:
529    resolution:
530      integrity: sha512-test
531      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
532    dev: false
533"#;
534
535        let mut file = NamedTempFile::new().unwrap();
536        file.write_all(yaml.as_bytes()).unwrap();
537
538        let parser = PnpmLockfileParser;
539        let result = parser.parse(file.path());
540        assert!(
541            result.is_ok(),
542            "Missing lockfileVersion should be accepted, got error: {:?}",
543            result.err()
544        );
545    }
546}