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
215#[allow(clippy::option_if_let_else, clippy::too_many_lines)] // Complex parsing with nested conditionals - imperative is clearer
216fn parse_package_key(lockfile_path: &Path, package_key: &str) -> Result<(String, String)> {
217    // Remove leading "/" if present
218    let key = package_key.trim_start_matches('/');
219
220    // Handle scoped packages like "@babel/core/7.22.5" or "/@babel/core@7.22.5"
221    if key.starts_with('@') {
222        // Find the second slash which separates the package name from the version
223        // For example: "@babel/core/7.22.5" -> ["@babel", "core", "7.22.5"]
224        let parts: Vec<&str> = key.splitn(3, '/').collect();
225        if parts.len() >= 3 {
226            // parts[0] = "@babel", parts[1] = "core", parts[2] = "7.22.5"
227            let name = format!("{}/{}", parts[0], parts[1]);
228            let version_part = parts[2];
229
230            // Remove pnpm peer dependency suffixes like "(@types/node@20.0.0)"
231            let version = version_part
232                .split('(')
233                .next()
234                .unwrap_or(version_part)
235                .trim_end_matches(')');
236
237            return Ok((name, version.to_string()));
238        } else if parts.len() == 2 {
239            // Handle "@babel/core@7.22.5" format (with @)
240            let name = format!(
241                "{}/{}",
242                parts[0],
243                parts[1].split('@').next().unwrap_or(parts[1])
244            );
245            let version = parts[1]
246                .split('@')
247                .nth(1)
248                .ok_or_else(|| Error::LockfileParseFailed {
249                    path: lockfile_path.to_path_buf(),
250                    message: format!("Invalid scoped package key format: {package_key}"),
251                })?;
252
253            let version = version
254                .split('(')
255                .next()
256                .unwrap_or(version)
257                .trim_end_matches(')');
258
259            return Ok((name, version.to_string()));
260        }
261    }
262
263    // Handle regular packages like "left-pad@1.3.0" or "left-pad/1.3.0"
264    // pnpm uses @ as separator between name and version
265    if let Some(at_idx) = key.rfind('@') {
266        let name = &key[..at_idx];
267        let version = &key[at_idx + 1..];
268
269        // Remove pnpm peer dependency suffixes
270        let version = version
271            .split('(')
272            .next()
273            .unwrap_or(version)
274            .trim_end_matches(')');
275
276        Ok((name.to_string(), version.to_string()))
277    } else if let Some(last_slash) = key.rfind('/') {
278        // Fallback to slash separator
279        let name = &key[..last_slash];
280        let version = &key[last_slash + 1..];
281
282        // Remove pnpm peer dependency suffixes
283        let version = version
284            .split('(')
285            .next()
286            .unwrap_or(version)
287            .trim_end_matches(')');
288
289        Ok((name.to_string(), version.to_string()))
290    } else {
291        Err(Error::LockfileParseFailed {
292            path: lockfile_path.to_path_buf(),
293            message: format!("Invalid pnpm package key format: {package_key}"),
294        })
295    }
296}
297
298#[allow(clippy::option_if_let_else)] // Complex parsing with nested conditionals - imperative is clearer
299fn determine_source(name: &str, package_info: &PnpmPackage) -> DependencySource {
300    if let Some(resolution) = &package_info.resolution {
301        match resolution {
302            PnpmResolution::Registry { tarball, .. } => DependencySource::Registry(tarball.clone()),
303            PnpmResolution::Git { repo, commit } => {
304                DependencySource::Git(format!("{repo}#{commit}"))
305            }
306            PnpmResolution::Object(map) => {
307                // Check for various resolution types
308                if let Some(tarball) = map.get("tarball").and_then(|v| v.as_str()) {
309                    DependencySource::Registry(tarball.to_string())
310                } else if let Some(repo) = map.get("repo").and_then(|v| v.as_str()) {
311                    let commit = map.get("commit").and_then(|v| v.as_str()).unwrap_or("HEAD");
312                    DependencySource::Git(format!("{repo}#{commit}"))
313                } else if let Some(dir) = map.get("directory").and_then(|v| v.as_str()) {
314                    DependencySource::Path(PathBuf::from(dir))
315                } else {
316                    // Default to registry with package name
317                    DependencySource::Registry(format!("npm:{name}"))
318                }
319            }
320        }
321    } else {
322        // No resolution info, assume npm registry
323        DependencySource::Registry(format!("npm:{name}"))
324    }
325}
326
327fn push_dependencies(target: &mut Vec<DependencyRef>, deps: &BTreeMap<String, String>) {
328    for (name, version_req) in deps {
329        target.push(DependencyRef {
330            name: name.clone(),
331            version_req: version_req.clone(),
332        });
333    }
334}
335
336#[cfg(test)]
337#[allow(clippy::needless_raw_string_hashes, clippy::uninlined_format_args)]
338mod tests {
339    use super::*;
340    use std::io::Write;
341    use tempfile::NamedTempFile;
342
343    #[test]
344    fn parses_basic_pnpm_lock() {
345        let yaml = r#"
346lockfileVersion: '6.0'
347
348importers:
349  .:
350    dependencies:
351      left-pad: 1.3.0
352
353packages:
354  /left-pad@1.3.0:
355    resolution:
356      integrity: sha512-test123
357      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
358    dev: false
359"#;
360
361        let mut file = NamedTempFile::new().unwrap();
362        file.write_all(yaml.as_bytes()).unwrap();
363
364        let parser = PnpmLockfileParser;
365        let entries = parser.parse(file.path()).unwrap();
366
367        assert!(entries.len() >= 2);
368
369        let workspace = entries
370            .iter()
371            .find(|e| e.is_workspace_member)
372            .expect("workspace root");
373        assert_eq!(workspace.dependencies.len(), 1);
374
375        let left_pad = entries
376            .iter()
377            .find(|e| e.name == "left-pad")
378            .expect("left-pad");
379        assert_eq!(left_pad.version, "1.3.0");
380        assert!(!left_pad.is_workspace_member);
381    }
382
383    #[test]
384    fn parses_scoped_packages() {
385        let yaml = r#"
386lockfileVersion: '6.0'
387
388importers:
389  .:
390    dependencies: {}
391
392packages:
393  /@babel/core@7.22.5:
394    resolution:
395      integrity: sha512-xyz
396      tarball: https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz
397    dev: false
398"#;
399
400        let mut file = NamedTempFile::new().unwrap();
401        file.write_all(yaml.as_bytes()).unwrap();
402
403        let parser = PnpmLockfileParser;
404        let entries = parser.parse(file.path()).unwrap();
405
406        let babel = entries
407            .iter()
408            .find(|e| e.name == "@babel/core")
409            .expect("@babel/core");
410        assert_eq!(babel.version, "7.22.5");
411    }
412
413    #[test]
414    fn supports_expected_filename() {
415        let parser = PnpmLockfileParser;
416        assert!(parser.supports_lockfile(Path::new("/tmp/pnpm-lock.yaml")));
417        assert!(!parser.supports_lockfile(Path::new("package-lock.json")));
418    }
419
420    #[test]
421    fn accepts_various_lockfile_versions() {
422        // Test that we accept various lockfile versions (5.4, 6.0, 9.0, etc.)
423        for version in ["5.4", "6.0", "7.0", "9.0", "10.0"] {
424            let yaml = format!(
425                r#"
426lockfileVersion: '{}'
427
428importers:
429  .:
430    dependencies:
431      left-pad: 1.3.0
432
433packages:
434  /left-pad@1.3.0:
435    resolution:
436      integrity: sha512-test
437      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
438    dev: false
439"#,
440                version
441            );
442
443            let mut file = NamedTempFile::new().unwrap();
444            file.write_all(yaml.as_bytes()).unwrap();
445
446            let parser = PnpmLockfileParser;
447            let result = parser.parse(file.path());
448            assert!(
449                result.is_ok(),
450                "Version {} should be accepted, got error: {:?}",
451                version,
452                result.err()
453            );
454        }
455    }
456
457    #[test]
458    fn rejects_invalid_lockfile_version_format() {
459        let yaml = r#"
460lockfileVersion: 'invalid'
461
462importers:
463  .:
464    dependencies: {}
465
466packages: {}
467"#;
468
469        let mut file = NamedTempFile::new().unwrap();
470        file.write_all(yaml.as_bytes()).unwrap();
471
472        let parser = PnpmLockfileParser;
473        let err = parser.parse(file.path()).unwrap_err();
474
475        match err {
476            Error::LockfileParseFailed { message, .. } => {
477                assert!(message.contains("Invalid pnpm lockfileVersion format"));
478                assert!(message.contains("invalid"));
479            }
480            other => panic!("Expected LockfileParseFailed, got: {:?}", other),
481        }
482    }
483
484    #[test]
485    fn accepts_supported_versions() {
486        for version in ["6.0", "9.0"] {
487            let yaml = format!(
488                r#"
489lockfileVersion: '{}'
490
491importers:
492  .:
493    dependencies:
494      left-pad: 1.3.0
495
496packages:
497  /left-pad@1.3.0:
498    resolution:
499      integrity: sha512-test
500      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
501    dev: false
502"#,
503                version
504            );
505
506            let mut file = NamedTempFile::new().unwrap();
507            file.write_all(yaml.as_bytes()).unwrap();
508
509            let parser = PnpmLockfileParser;
510            let result = parser.parse(file.path());
511            assert!(
512                result.is_ok(),
513                "Version {} should be supported, got error: {:?}",
514                version,
515                result.err()
516            );
517        }
518    }
519
520    #[test]
521    fn accepts_missing_lockfile_version() {
522        // Older pnpm versions may not have lockfileVersion
523        let yaml = r#"
524importers:
525  .:
526    dependencies:
527      left-pad: 1.3.0
528
529packages:
530  /left-pad@1.3.0:
531    resolution:
532      integrity: sha512-test
533      tarball: https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz
534    dev: false
535"#;
536
537        let mut file = NamedTempFile::new().unwrap();
538        file.write_all(yaml.as_bytes()).unwrap();
539
540        let parser = PnpmLockfileParser;
541        let result = parser.parse(file.path());
542        assert!(
543            result.is_ok(),
544            "Missing lockfileVersion should be accepted, got error: {:?}",
545            result.err()
546        );
547    }
548}