ncu/parsers/
package_json.rs1use anyhow::{Context, Result};
2use check_updates_core::{Dependency, VersionSpec};
3use std::fs;
4use std::path::Path;
5
6pub struct PackageJsonParser;
7
8impl PackageJsonParser {
9 pub fn new() -> Self {
10 Self
11 }
12
13 pub fn parse(&self, path: &Path) -> Result<Vec<Dependency>> {
15 let content = fs::read_to_string(path)
16 .with_context(|| format!("Failed to read {}", path.display()))?;
17
18 let parsed: serde_json::Value = serde_json::from_str(&content)
19 .with_context(|| format!("Failed to parse JSON in {}", path.display()))?;
20
21 let mut deps = Vec::new();
22
23 if let Some(dependencies) = parsed.get("dependencies").and_then(|v| v.as_object()) {
25 deps.extend(self.parse_deps(dependencies, path, &content));
26 }
27
28 if let Some(dev_deps) = parsed.get("devDependencies").and_then(|v| v.as_object()) {
30 deps.extend(self.parse_deps(dev_deps, path, &content));
31 }
32
33 if let Some(peer_deps) = parsed.get("peerDependencies").and_then(|v| v.as_object()) {
35 deps.extend(self.parse_deps(peer_deps, path, &content));
36 }
37
38 if let Some(opt_deps) = parsed.get("optionalDependencies").and_then(|v| v.as_object()) {
40 deps.extend(self.parse_deps(opt_deps, path, &content));
41 }
42
43 Ok(deps)
44 }
45
46 fn parse_deps(
47 &self,
48 deps: &serde_json::Map<String, serde_json::Value>,
49 source_file: &Path,
50 content: &str,
51 ) -> Vec<Dependency> {
52 let mut result = Vec::new();
53
54 for (name, version_value) in deps {
55 if let Some(version_str) = version_value.as_str() {
56 if version_str.starts_with("git")
58 || version_str.starts_with("file:")
59 || version_str.starts_with("link:")
60 || version_str.starts_with("workspace:")
61 || version_str.contains("github:")
62 || version_str.contains("://")
63 {
64 continue;
65 }
66
67 if let Ok(version_spec) = Self::parse_npm_version(version_str) {
68 let line_number = Self::find_line_number(content, name);
69 let original_line = content
70 .lines()
71 .nth(line_number.saturating_sub(1))
72 .unwrap_or("")
73 .to_string();
74
75 result.push(Dependency {
76 name: name.clone(),
77 version_spec,
78 source_file: source_file.to_path_buf(),
79 line_number,
80 original_line,
81 });
82 }
83 }
84 }
85
86 result
87 }
88
89 fn parse_npm_version(s: &str) -> Result<VersionSpec> {
91 let s = s.trim();
92
93 VersionSpec::parse(s).map_err(|e| anyhow::anyhow!("{e}"))
96 }
97
98 fn find_line_number(content: &str, package_name: &str) -> usize {
99 for (i, line) in content.lines().enumerate() {
100 if line.contains(&format!("\"{package_name}\"")) {
101 return i + 1;
102 }
103 }
104 1
105 }
106}
107
108impl Default for PackageJsonParser {
109 fn default() -> Self {
110 Self::new()
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::io::Write;
118 use tempfile::NamedTempFile;
119
120 #[test]
121 fn test_parse_dependencies() -> Result<()> {
122 let mut file = NamedTempFile::new()?;
123 writeln!(
124 file,
125 r#"{{
126 "name": "test",
127 "dependencies": {{
128 "express": "^4.18.0",
129 "lodash": "~4.17.0"
130 }},
131 "devDependencies": {{
132 "typescript": "^5.0.0"
133 }}
134}}"#
135 )?;
136
137 let parser = PackageJsonParser::new();
138 let deps = parser.parse(&file.path().to_path_buf())?;
139
140 assert_eq!(deps.len(), 3);
141
142 let express = deps.iter().find(|d| d.name == "express").unwrap();
143 assert_eq!(express.version_spec.version_string().unwrap(), "4.18.0");
144
145 Ok(())
146 }
147
148 #[test]
149 fn test_skip_git_deps() -> Result<()> {
150 let mut file = NamedTempFile::new()?;
151 writeln!(
152 file,
153 r#"{{
154 "dependencies": {{
155 "express": "^4.18.0",
156 "my-pkg": "git+https://github.com/user/repo.git",
157 "local": "file:../local"
158 }}
159}}"#
160 )?;
161
162 let parser = PackageJsonParser::new();
163 let deps = parser.parse(&file.path().to_path_buf())?;
164
165 assert_eq!(deps.len(), 1);
166 assert_eq!(deps[0].name, "express");
167
168 Ok(())
169 }
170}