1use std::path::Path;
5
6use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
7use crate::parser_warn as warn;
8
9use super::super::PackageParser;
10use super::super::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
11use super::{
12 build_nuget_party, build_nuget_purl, build_nuget_urls, default_package_data,
13 insert_extra_string,
14};
15
16const NUGET_PROJECT_JSON_KEYS: &[&str] = &[
17 "dependencies",
18 "frameworks",
19 "runtimes",
20 "supports",
21 "imports",
22 "tools",
23 "commands",
24 "scripts",
25 "buildOptions",
26 "packOptions",
27 "publishOptions",
28 "compilationOptions",
29];
30
31pub struct ProjectJsonParser;
32
33impl PackageParser for ProjectJsonParser {
34 const PACKAGE_TYPE: PackageType = PackageType::Nuget;
35
36 fn is_match(path: &Path) -> bool {
37 path.file_name()
38 .and_then(|name| name.to_str())
39 .is_some_and(|name| name == "project.json")
40 }
41
42 fn extract_packages(path: &Path) -> Vec<PackageData> {
43 let content = match read_file_to_string(path, None) {
44 Ok(c) => c,
45 Err(e) => {
46 warn!("Failed to read project.json at {:?}: {}", path, e);
47 return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
48 }
49 };
50
51 if !looks_like_probable_nuget_project_json_text(&content) {
52 return Vec::new();
53 }
54
55 let parsed: serde_json::Value = match serde_json::from_str(&content) {
56 Ok(value) => value,
57 Err(e) => {
58 warn!("Failed to parse project.json at {:?}: {}", path, e);
59 return vec![default_package_data(Some(DatasourceId::NugetProjectJson))];
60 }
61 };
62
63 if !looks_like_probable_nuget_project_json_value(&parsed) {
64 return Vec::new();
65 }
66
67 vec![parse_project_json_manifest(&parsed)]
68 }
69}
70
71pub struct ProjectLockJsonParser;
72
73impl PackageParser for ProjectLockJsonParser {
74 const PACKAGE_TYPE: PackageType = PackageType::Nuget;
75
76 fn is_match(path: &Path) -> bool {
77 path.file_name()
78 .and_then(|name| name.to_str())
79 .is_some_and(|name| name == "project.lock.json")
80 }
81
82 fn extract_packages(path: &Path) -> Vec<PackageData> {
83 let content = match read_file_to_string(path, None) {
84 Ok(c) => c,
85 Err(e) => {
86 warn!("Failed to read project.lock.json at {:?}: {}", path, e);
87 return vec![default_package_data(Some(
88 DatasourceId::NugetProjectLockJson,
89 ))];
90 }
91 };
92
93 let parsed: serde_json::Value = match serde_json::from_str(&content) {
94 Ok(value) => value,
95 Err(e) => {
96 warn!("Failed to parse project.lock.json at {:?}: {}", path, e);
97 return vec![default_package_data(Some(
98 DatasourceId::NugetProjectLockJson,
99 ))];
100 }
101 };
102
103 vec![parse_project_lock_manifest(&parsed)]
104 }
105}
106
107fn parse_project_json_manifest(parsed: &serde_json::Value) -> PackageData {
108 let name = parsed
109 .get("name")
110 .and_then(|value| value.as_str())
111 .map(|value| value.to_string());
112 let version = parsed
113 .get("version")
114 .and_then(|value| value.as_str())
115 .map(|value| value.to_string());
116 let description = parsed
117 .get("description")
118 .and_then(|value| value.as_str())
119 .map(|value| value.to_string());
120 let homepage_url = parsed
121 .get("projectUrl")
122 .and_then(|value| value.as_str())
123 .map(|value| value.to_string());
124 let extracted_license_statement = parsed
125 .get("license")
126 .or_else(|| parsed.get("licenseUrl"))
127 .and_then(|value| value.as_str())
128 .map(|value| value.to_string());
129
130 let mut parties = Vec::new();
131 if let Some(authors) = parsed.get("authors") {
132 let author_name = if let Some(value) = authors.as_str() {
133 Some(value.to_string())
134 } else {
135 authors.as_array().map(|entries| {
136 entries
137 .iter()
138 .filter_map(|entry| entry.as_str())
139 .collect::<Vec<_>>()
140 .join(", ")
141 })
142 };
143
144 if let Some(author_name) = author_name.filter(|value| !value.is_empty()) {
145 parties.push(build_nuget_party("author", author_name));
146 }
147 }
148
149 let mut dependencies = Vec::new();
150
151 if let Some(root_dependencies) = parsed
152 .get("dependencies")
153 .and_then(|value| value.as_object())
154 {
155 for (dependency_name, dependency_spec) in root_dependencies.iter().take(MAX_ITERATION_COUNT)
156 {
157 if let Some(dependency) =
158 parse_project_json_dependency(dependency_name, dependency_spec, None)
159 {
160 dependencies.push(dependency);
161 }
162 }
163 }
164
165 if let Some(frameworks) = parsed.get("frameworks").and_then(|value| value.as_object()) {
166 for (framework, framework_value) in frameworks.iter().take(MAX_ITERATION_COUNT) {
167 let Some(framework_dependencies) = framework_value
168 .get("dependencies")
169 .and_then(|value| value.as_object())
170 else {
171 continue;
172 };
173
174 for (dependency_name, dependency_spec) in
175 framework_dependencies.iter().take(MAX_ITERATION_COUNT)
176 {
177 if let Some(dependency) = parse_project_json_dependency(
178 dependency_name,
179 dependency_spec,
180 Some(framework.clone()),
181 ) {
182 dependencies.push(dependency);
183 }
184 }
185 }
186 }
187
188 let (repository_homepage_url, repository_download_url, api_data_url) =
189 build_nuget_urls(name.as_deref(), version.as_deref());
190
191 PackageData {
192 datasource_id: Some(DatasourceId::NugetProjectJson),
193 package_type: Some(PackageType::Nuget),
194 name: name.clone().map(truncate_field),
195 version: version.clone().map(truncate_field),
196 purl: build_nuget_purl(name.as_deref(), version.as_deref()),
197 description: description.map(truncate_field),
198 homepage_url: homepage_url.map(truncate_field),
199 parties,
200 dependencies,
201 extracted_license_statement: extracted_license_statement.map(truncate_field),
202 repository_homepage_url,
203 repository_download_url,
204 api_data_url,
205 ..default_package_data(Some(DatasourceId::NugetProjectJson))
206 }
207}
208
209fn looks_like_probable_nuget_project_json_text(content: &str) -> bool {
210 NUGET_PROJECT_JSON_KEYS
211 .iter()
212 .any(|key| content.contains(&format!("\"{key}\"")))
213}
214
215fn looks_like_probable_nuget_project_json_value(parsed: &serde_json::Value) -> bool {
216 let Some(object) = parsed.as_object() else {
217 return false;
218 };
219
220 NUGET_PROJECT_JSON_KEYS
221 .iter()
222 .any(|key| object.contains_key(*key))
223}
224
225fn parse_project_json_dependency(
226 dependency_name: &str,
227 dependency_spec: &serde_json::Value,
228 scope: Option<String>,
229) -> Option<Dependency> {
230 let mut extra_data = serde_json::Map::new();
231
232 let requirement = match dependency_spec {
233 serde_json::Value::String(version) => Some(version.clone()),
234 serde_json::Value::Object(object) => {
235 let requirement = object
236 .get("version")
237 .and_then(|value| value.as_str())
238 .map(|value| value.to_string());
239 insert_extra_string(
240 &mut extra_data,
241 "include",
242 object
243 .get("include")
244 .and_then(|value| value.as_str())
245 .map(|value| value.to_string()),
246 );
247 insert_extra_string(
248 &mut extra_data,
249 "exclude",
250 object
251 .get("exclude")
252 .and_then(|value| value.as_str())
253 .map(|value| value.to_string()),
254 );
255 insert_extra_string(
256 &mut extra_data,
257 "type",
258 object
259 .get("type")
260 .and_then(|value| value.as_str())
261 .map(|value| value.to_string()),
262 );
263 requirement
264 }
265 _ => return None,
266 };
267
268 Some(Dependency {
269 purl: build_nuget_purl(Some(dependency_name), None),
270 extracted_requirement: requirement,
271 scope,
272 is_runtime: Some(true),
273 is_optional: Some(false),
274 is_pinned: Some(false),
275 is_direct: Some(true),
276 resolved_package: None,
277 extra_data: if extra_data.is_empty() {
278 None
279 } else {
280 Some(extra_data.into_iter().collect())
281 },
282 })
283}
284
285fn parse_project_lock_manifest(parsed: &serde_json::Value) -> PackageData {
286 let mut dependencies = Vec::new();
287
288 if let Some(groups) = parsed
289 .get("projectFileDependencyGroups")
290 .and_then(|value| value.as_object())
291 {
292 for (framework, entries) in groups.iter().take(MAX_ITERATION_COUNT) {
293 let Some(entries) = entries.as_array() else {
294 continue;
295 };
296
297 for entry in entries
298 .iter()
299 .take(MAX_ITERATION_COUNT)
300 .filter_map(|value| value.as_str())
301 {
302 if let Some(dependency) = parse_project_lock_dependency(
303 entry,
304 (!framework.is_empty()).then(|| framework.clone()),
305 ) {
306 dependencies.push(dependency);
307 }
308 }
309 }
310 }
311
312 PackageData {
313 datasource_id: Some(DatasourceId::NugetProjectLockJson),
314 package_type: Some(PackageType::Nuget),
315 dependencies,
316 ..default_package_data(Some(DatasourceId::NugetProjectLockJson))
317 }
318}
319
320fn parse_project_lock_dependency(entry: &str, scope: Option<String>) -> Option<Dependency> {
321 let trimmed = entry.trim();
322 if trimmed.is_empty() {
323 return None;
324 }
325
326 let mut parts = trimmed.split_whitespace();
327 let name = parts.next()?;
328 let requirement = parts.collect::<Vec<_>>().join(" ");
329
330 Some(Dependency {
331 purl: build_nuget_purl(Some(name), None),
332 extracted_requirement: (!requirement.is_empty()).then_some(requirement),
333 scope,
334 is_runtime: Some(true),
335 is_optional: Some(false),
336 is_pinned: Some(false),
337 is_direct: Some(true),
338 resolved_package: None,
339 extra_data: None,
340 })
341}
342
343crate::register_parser!(
344 "Legacy .NET project.json manifest",
345 &["**/project.json"],
346 "nuget",
347 "C#",
348 Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
349);
350
351crate::register_parser!(
352 ".NET project.lock.json lockfile",
353 &["**/project.lock.json"],
354 "nuget",
355 "C#",
356 Some("https://learn.microsoft.com/en-us/nuget/archive/project-json"),
357);