Skip to main content

ncu/parsers/
lockfiles.rs

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    /// Parse installed versions from a lock file
18    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    /// Parse package-lock.json (npm)
28    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        // npm v7+ format: packages field with "" for root and "node_modules/pkg" for deps
38        if let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) {
39            for (key, pkg_data) in packages {
40                // Skip root package (empty key)
41                if key.is_empty() {
42                    continue;
43                }
44
45                // Extract package name from path (node_modules/name or node_modules/@scope/name)
46                let name = key
47                    .strip_prefix("node_modules/")
48                    .unwrap_or(key)
49                    .to_string();
50
51                // Skip nested node_modules
52                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        // npm v6 format: dependencies field
63        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    /// Parse pnpm-lock.yaml
84    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        // pnpm v9 format: snapshots or packages
94        // Package entries like: "express@4.18.2" or "express@4.18.2(supports-color@8.0.0)"
95        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        // Also check snapshots (pnpm v9)
105        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    /// Parse pnpm package key like "express@4.18.2" or "@types/node@20.0.0"
118    fn parse_pnpm_package_key(key: &str) -> Option<(String, Version)> {
119        // Handle scoped packages: @scope/name@version
120        let (name, version_str) = if let Some(rest) = key.strip_prefix('@') {
121            // Find the second @ which separates name from version
122            if let Some(at_pos) = rest.find('@') {
123                let name = &key[..at_pos + 1];
124                let version_part = &rest[at_pos + 1..];
125                // Remove any peer dep suffix like (supports-color@8.0.0)
126                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            // Regular package: name@version
133            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    /// Parse yarn.lock (yarn classic and berry)
145    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        // Yarn lock format is custom, not YAML
152        // Entry format:
153        // "package@^1.0.0":
154        //   version "1.2.3"
155        let mut current_packages: Vec<String> = Vec::new();
156
157        for line in content.lines() {
158            let trimmed = line.trim();
159
160            // Package header line (may have multiple packages)
161            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            // Version line
174            if trimmed.starts_with("version")
175                && let Some(version) = Self::parse_yarn_version_line(trimmed) {
176                    for pkg in &current_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        // Format: "pkg@^1.0.0", "pkg@~1.0.0":
187        // or: pkg@^1.0.0, pkg@~1.0.0:
188        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            // Extract package name (before the @version part)
194            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        // Handle @scope/name@version
204        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        // version "1.2.3" or version: "1.2.3"
216        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    /// Parse bun.lockb (binary format - limited support)
223    fn parse_bun_lock(&self, _path: &Path) -> Result<HashMap<String, Version>> {
224        // bun.lockb is a binary format, difficult to parse without bun itself
225        // For now, return empty and rely on package.json versions
226        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}