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